From 20f2ef07b4379dd48726513264670031c98b8208 Mon Sep 17 00:00:00 2001 From: Lysander Su <773678591@qq.com> Date: Wed, 8 Jul 2026 16:04:09 +0800 Subject: [PATCH 01/15] fix(opencode): tolerate older auto-approval flags (#1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(opencode): tolerate older auto-approval flags Why: #1100 showed the #1065 preflight turned missing --auto into a startup blocker. Select the best advertised approval flag and preserve previous no-default-flag behavior when none exists. [砚砚/GPT-5.5🐾] * test(public): sync source gate fixtures Why: #1101 was rebased onto current clowder main after the source PR merged, but the public repo still carried stale source-synced test gate fixtures. Sync the F236 public-test exclusion date and drift-route owner test setup from source main so the downstream hotfix can pass the normal public CI gate without changing the OpenCode behavior patch. [砚砚/GPT-5.5🐾] --------- Co-authored-by: MaineCoon-GPT-5.5 <26771442+zts212653@users.noreply.github.com> --- .../api/config/public-test-exclusions.json | 2 +- .../agents/providers/OpenCodeAgentService.ts | 156 ++++++++---------- .../providers/opencode-auto-approval.ts | 139 ++++++++++++++++ .../api/test/drift-mcp-global-resolve.test.js | 7 + .../api/test/opencode-mcp-isolation.test.js | 110 ++++++++++-- 5 files changed, 307 insertions(+), 107 deletions(-) create mode 100644 packages/api/src/domains/cats/services/agents/providers/opencode-auto-approval.ts diff --git a/packages/api/config/public-test-exclusions.json b/packages/api/config/public-test-exclusions.json index 130639db51..e19a2b5a0c 100644 --- a/packages/api/config/public-test-exclusions.json +++ b/packages/api/config/public-test-exclusions.json @@ -341,7 +341,7 @@ "reason": "F236 cc anchor hook coverage imports the source-only root .claude hook implementation that is not part of the public export.", "owner": "@zts212653", "introducedBy": "68dd499d9", - "expiresOn": "2026-07-07" + "expiresOn": "2026-07-31" }, { "id": "github-schedule-factories", diff --git a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts index 407af55eaa..cc85b3f5ad 100644 --- a/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/OpenCodeAgentService.ts @@ -20,18 +20,22 @@ import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { buildCliDiagnostics, buildSilentCompletionDiagnostic } from '../../../../../utils/cli-diagnostics.js'; import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; -import { - isCliError, - isCliPlainTextResult, - isCliTimeout, - isLivenessWarning, - spawnCli, -} from '../../../../../utils/cli-spawn.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; import type { SpawnFn } from '../../../../../utils/cli-types.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentServiceOptions, L0InjectableAgentService, MessageMetadata } from '../../types.js'; import type { RawArchiveSink } from '../providers/codex-audit-hooks.js'; import { sanitizeRawEvent } from '../providers/codex-audit-hooks.js'; +import { + cacheOpenCodeAutoApproveProbe, + getCliFlagName, + OPENCODE_AUTO_APPROVE_FLAG, + type OpenCodeAutoApproveProbeFn, + type OpenCodeAutoApproveProbeResult, + parseOpenCodeCliConfigArgs, + probeOpenCodeAutoApproveSupport, + userControlsOpenCodeAutoApprove, +} from './opencode-auto-approval.js'; import { transformOpenCodeEvent } from './opencode-event-transform.js'; const log = createModuleLogger('opencode-agent'); @@ -57,27 +61,6 @@ interface OpenCodeAgentServiceOptions { const OPENCODE_API_KEY_ENV = 'OPENCODE_API_KEY'; const ANTHROPIC_API_KEY_ENV = 'ANTHROPIC_API_KEY'; const ANTHROPIC_BASE_URL_ENV = 'ANTHROPIC_BASE_URL'; -const OPENCODE_AUTO_APPROVE_FLAG = '--auto'; -const OPENCODE_AUTO_APPROVE_FLAG_ALIASES = new Set([ - OPENCODE_AUTO_APPROVE_FLAG, - '--yolo', - '--dangerously-skip-permissions', - '--no-auto', - '--no-yolo', - '--no-dangerously-skip-permissions', -]); -const OPENCODE_AUTO_APPROVE_MIN_VERSION = '1.17.12'; -const OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS = 10_000; -const OPENCODE_AUTO_APPROVE_UNSUPPORTED_MESSAGE = `OpenCode 版本过低,不支持 --auto 自动审批;请升级 opencode-ai 到 >= ${OPENCODE_AUTO_APPROVE_MIN_VERSION} 后重试。`; -const OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE = `无法确认 OpenCode 是否支持 --auto 自动审批;请升级 opencode-ai 到 >= ${OPENCODE_AUTO_APPROVE_MIN_VERSION} 后重试。`; - -type OpenCodeAutoApproveProbeResult = { supported: true } | { supported: false; message: string }; -type OpenCodeAutoApproveProbeFn = (options: { - command: string; - cwd?: string; - env?: Record; -}) => Promise; - // Process-wide cache: --auto support is a property of the installed opencode binary. // Restart the API process after upgrading opencode so this capability is re-probed. let sharedOpenCodeAutoApproveProbe: Promise | undefined; @@ -105,49 +88,6 @@ function summarizeDebugSecret(value: string | null | undefined): string { return `${value.slice(0, 6)}***`; } -function getCliFlagName(part: string): string | null { - if (!part.startsWith('-')) return null; - const equalsIndex = part.indexOf('='); - return equalsIndex > 0 ? part.slice(0, equalsIndex) : part; -} - -async function probeOpenCodeAutoApproveSupport( - command: string, - cwd?: string, - env?: Record, -): Promise { - let helpText = ''; - try { - for await (const event of spawnCli({ - command, - args: ['run', '--help'], - outputMode: 'plainText', - ...(cwd ? { cwd } : {}), - ...(env ? { env } : {}), - timeoutMs: OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS, - })) { - if (isCliPlainTextResult(event)) { - helpText = `${event.stdout}\n${event.stderr}`; - continue; - } - if (isCliTimeout(event)) { - log.warn({ command, timeoutMs: OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS }, 'OpenCode --auto probe timed out'); - return { supported: false, message: OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE }; - } - if (isCliError(event)) { - log.warn({ command, exitCode: event.exitCode, signal: event.signal }, 'OpenCode --auto probe failed'); - return { supported: false, message: OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE }; - } - } - } catch (err) { - log.warn({ command, err }, 'OpenCode --auto probe threw'); - return { supported: false, message: OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE }; - } - - if (helpText.includes('--auto')) return { supported: true }; - return { supported: false, message: OPENCODE_AUTO_APPROVE_UNSUPPORTED_MESSAGE }; -} - export function summarizeOpenCodeEnvForDebug(env: Record | undefined): OpenCodeEnvDebugSummary { const profileMode = env?.CAT_CAFE_ANTHROPIC_PROFILE_MODE ?? '(unset)'; const hasRuntimeConfig = Boolean(env?.OPENCODE_CONFIG); @@ -243,8 +183,19 @@ export class OpenCodeAgentService implements L0InjectableAgentService { return; } - await this.ensureAutoApproveSupported(opencodeCommand, cwd, childEnv); - const args = this.buildArgs(prompt, options?.sessionId, effectiveModel, options?.cliConfigArgs); + const defaultAutoApproveFlag = await this.resolveDefaultAutoApproveFlag( + opencodeCommand, + cwd, + childEnv, + options?.cliConfigArgs, + ); + const args = this.buildArgs( + prompt, + options?.sessionId, + effectiveModel, + options?.cliConfigArgs, + defaultAutoApproveFlag, + ); log.debug( { @@ -479,7 +430,13 @@ export class OpenCodeAgentService implements L0InjectableAgentService { } } - private buildArgs(prompt: string, sessionId?: string, model?: string, cliConfigArgs?: readonly string[]): string[] { + private buildArgs( + prompt: string, + sessionId?: string, + model?: string, + cliConfigArgs?: readonly string[], + defaultAutoApproveFlag?: string, + ): string[] { const args = ['run']; // Session resume @@ -495,18 +452,16 @@ export class OpenCodeAgentService implements L0InjectableAgentService { // JSON event stream output args.push('--format', 'json'); - // Headless OpenCode has no human approval bridge. --auto is the public - // opencode flag since 1.17.12; ensureAutoApproveSupported gates older CLIs. - args.push(OPENCODE_AUTO_APPROVE_FLAG); + // Headless OpenCode has no human approval bridge. Use the best approval + // flag advertised by this installed CLI; older compatible builds may have + // no supported flag, in which case we preserve the pre-#1065 behavior. + if (defaultAutoApproveFlag) args.push(defaultAutoApproveFlag); // User-defined CLI args from the member editor (#567). // User args win when they overlap with system-injected flags. - const userParts: string[] = []; - for (const arg of cliConfigArgs ?? []) { - userParts.push(...arg.trim().split(/\s+/)); - } + const userParts = parseOpenCodeCliConfigArgs(cliConfigArgs); const userFlags = new Set(userParts.map(getCliFlagName).filter((flag): flag is string => flag !== null)); - const userControlsAutoApprove = Array.from(OPENCODE_AUTO_APPROVE_FLAG_ALIASES).some((flag) => userFlags.has(flag)); + const userControlsAutoApprove = userControlsOpenCodeAutoApprove(userFlags); const deduped: string[] = []; for (let i = 0; i < args.length; i++) { const flagName = getCliFlagName(args[i]); @@ -524,15 +479,24 @@ export class OpenCodeAgentService implements L0InjectableAgentService { return deduped; } - private async ensureAutoApproveSupported( + private async resolveDefaultAutoApproveFlag( command: string, cwd?: string, env?: Record, - ): Promise { + cliConfigArgs?: readonly string[], + ): Promise { + const userParts = parseOpenCodeCliConfigArgs(cliConfigArgs); + const userFlags = new Set(userParts.map(getCliFlagName).filter((flag): flag is string => flag !== null)); + if (userControlsOpenCodeAutoApprove(userFlags)) return undefined; + const result = await this.getAutoApproveProbe(command, cwd, env); - // Reject before launching the real headless run. invoke() surfaces this as - // error + done with upgrade guidance instead of continuing without approvals. - if (!result.supported) throw new Error(result.message); + if (result.warning) { + log.warn( + { catId: this.catId, command, warning: result.warning }, + 'OpenCode auto-approval flag unavailable; continuing without default flag', + ); + } + return result.approvalFlag; } private getAutoApproveProbe( @@ -541,15 +505,25 @@ export class OpenCodeAgentService implements L0InjectableAgentService { env?: Record, ): Promise { if (this.autoApproveProbeFn) { - this.autoApproveProbe ??= this.autoApproveProbeFn({ command, ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) }); + this.autoApproveProbe ??= cacheOpenCodeAutoApproveProbe( + this.autoApproveProbeFn({ command, ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) }), + (promise) => { + if (this.autoApproveProbe === promise) this.autoApproveProbe = undefined; + }, + ); return this.autoApproveProbe; } // Unit tests inject spawnFn to own the primary CLI process lifecycle. Do not // consume that mock for the preflight probe unless the test provides an // explicit autoApproveProbeFn. - if (this.spawnFn) return Promise.resolve({ supported: true }); - - sharedOpenCodeAutoApproveProbe ??= probeOpenCodeAutoApproveSupport(command, cwd, env); + if (this.spawnFn) return Promise.resolve({ approvalFlag: OPENCODE_AUTO_APPROVE_FLAG }); + + sharedOpenCodeAutoApproveProbe ??= cacheOpenCodeAutoApproveProbe( + probeOpenCodeAutoApproveSupport(command, cwd, env), + (promise) => { + if (sharedOpenCodeAutoApproveProbe === promise) sharedOpenCodeAutoApproveProbe = undefined; + }, + ); return sharedOpenCodeAutoApproveProbe; } diff --git a/packages/api/src/domains/cats/services/agents/providers/opencode-auto-approval.ts b/packages/api/src/domains/cats/services/agents/providers/opencode-auto-approval.ts new file mode 100644 index 0000000000..0aea7351a8 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/opencode-auto-approval.ts @@ -0,0 +1,139 @@ +import { createModuleLogger } from '../../../../../infrastructure/logger.js'; +import { isCliError, isCliPlainTextResult, isCliTimeout, spawnCli } from '../../../../../utils/cli-spawn.js'; + +const log = createModuleLogger('opencode-agent'); + +export const OPENCODE_AUTO_APPROVE_FLAG = '--auto'; +const OPENCODE_DEFAULT_AUTO_APPROVE_FLAGS = [ + OPENCODE_AUTO_APPROVE_FLAG, + '--dangerously-skip-permissions', + '--yolo', +] as const; +const OPENCODE_AUTO_APPROVE_FLAG_ALIASES = new Set([ + ...OPENCODE_DEFAULT_AUTO_APPROVE_FLAGS, + '--no-auto', + '--no-yolo', + '--no-dangerously-skip-permissions', +]); +const OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS = 10_000; +const OPENCODE_AUTO_APPROVE_UNAVAILABLE_MESSAGE = + 'OpenCode run --help did not advertise a supported auto-approval flag; continuing without default approval flag injection.'; +const OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE = + 'Unable to confirm OpenCode auto-approval flag support; continuing without default approval flag injection.'; + +export type OpenCodeAutoApproveProbeResult = { approvalFlag?: string; warning?: string; cacheable?: boolean }; +export type OpenCodeAutoApproveProbeFn = (options: { + command: string; + cwd?: string; + env?: Record; +}) => Promise; +type OpenCodeHelpProbeResult = + | { status: 'ok'; helpText: string } + | { status: 'unsupported' } + | { status: 'transient-failure' }; + +export function getCliFlagName(part: string): string | null { + if (!part.startsWith('-')) return null; + const equalsIndex = part.indexOf('='); + return equalsIndex > 0 ? part.slice(0, equalsIndex) : part; +} + +export function parseOpenCodeCliConfigArgs(cliConfigArgs?: readonly string[]): string[] { + const userParts: string[] = []; + for (const arg of cliConfigArgs ?? []) { + userParts.push(...arg.trim().split(/\s+/)); + } + return userParts; +} + +export function userControlsOpenCodeAutoApprove(userFlags: ReadonlySet): boolean { + return Array.from(OPENCODE_AUTO_APPROVE_FLAG_ALIASES).some((flag) => userFlags.has(flag)); +} + +async function probeOpenCodeHelp( + command: string, + args: readonly string[], + cwd?: string, + env?: Record, + options?: { cliErrorAsUnsupported?: boolean }, +): Promise { + let helpText = ''; + try { + for await (const event of spawnCli({ + command, + args, + outputMode: 'plainText', + ...(cwd ? { cwd } : {}), + ...(env ? { env } : {}), + timeoutMs: OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS, + })) { + if (isCliPlainTextResult(event)) { + helpText = `${event.stdout}\n${event.stderr}`; + continue; + } + if (isCliTimeout(event)) { + log.warn({ command, timeoutMs: OPENCODE_AUTO_APPROVE_PROBE_TIMEOUT_MS }, 'OpenCode help probe timed out'); + return { status: 'transient-failure' }; + } + if (isCliError(event)) { + log.warn({ command, exitCode: event.exitCode, signal: event.signal }, 'OpenCode help probe failed'); + return options?.cliErrorAsUnsupported ? { status: 'unsupported' } : { status: 'transient-failure' }; + } + } + } catch (err) { + log.warn({ command, err }, 'OpenCode help probe threw'); + return { status: 'transient-failure' }; + } + + return { status: 'ok', helpText }; +} + +export async function probeOpenCodeAutoApproveSupport( + command: string, + cwd?: string, + env?: Record, +): Promise { + const visibleHelp = await probeOpenCodeHelp(command, ['run', '--help'], cwd, env); + if (visibleHelp.status !== 'ok') { + return { warning: OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE, cacheable: false }; + } + + for (const flag of OPENCODE_DEFAULT_AUTO_APPROVE_FLAGS) { + if (visibleHelp.helpText.includes(flag)) return { approvalFlag: flag }; + } + + for (const flag of OPENCODE_DEFAULT_AUTO_APPROVE_FLAGS.slice(1)) { + const hiddenHelp = await probeOpenCodeHelp(command, ['run', flag, '--help'], cwd, env, { + cliErrorAsUnsupported: true, + }); + if (hiddenHelp.status === 'ok') return { approvalFlag: flag }; + if (hiddenHelp.status === 'transient-failure') { + return { warning: OPENCODE_AUTO_APPROVE_PROBE_FAILED_MESSAGE, cacheable: false }; + } + } + + return { warning: OPENCODE_AUTO_APPROVE_UNAVAILABLE_MESSAGE, cacheable: true }; +} + +function isOpenCodeAutoApproveProbeCacheable(result: OpenCodeAutoApproveProbeResult): boolean { + if (result.cacheable !== undefined) return result.cacheable; + if (result.warning && !result.approvalFlag) return result.warning === OPENCODE_AUTO_APPROVE_UNAVAILABLE_MESSAGE; + return true; +} + +export function cacheOpenCodeAutoApproveProbe( + probe: Promise, + clearCache: (promise: Promise) => void, +): Promise { + const cachedProbe = probe.then( + (result) => { + if (!isOpenCodeAutoApproveProbeCacheable(result)) clearCache(cachedProbe); + return result; + }, + (err) => { + clearCache(cachedProbe); + throw err; + }, + ); + return cachedProbe; +} diff --git a/packages/api/test/drift-mcp-global-resolve.test.js b/packages/api/test/drift-mcp-global-resolve.test.js index 4950caecf2..436e0c109d 100644 --- a/packages/api/test/drift-mcp-global-resolve.test.js +++ b/packages/api/test/drift-mcp-global-resolve.test.js @@ -23,12 +23,14 @@ const AUTH_HEADERS = { host: 'localhost:3004', origin: 'http://localhost:3003', }; +const ORIGINAL_OWNER_USER_ID = process.env.DEFAULT_OWNER_USER_ID; describe('#1050 — MCP drift resolve without projectPath', () => { /** @type {import('fastify').FastifyInstance} */ let app; beforeEach(async () => { + process.env.DEFAULT_OWNER_USER_ID = AUTH_HEADERS['x-test-session-user']; app = Fastify({ logger: false }); app.addHook('preHandler', async (request) => { const raw = request.headers['x-test-session-user']; @@ -42,6 +44,8 @@ describe('#1050 — MCP drift resolve without projectPath', () => { afterEach(async () => { await app?.close(); + if (ORIGINAL_OWNER_USER_ID === undefined) delete process.env.DEFAULT_OWNER_USER_ID; + else process.env.DEFAULT_OWNER_USER_ID = ORIGINAL_OWNER_USER_ID; }); it('does not reject MCP resolve when projectPath is absent', async () => { @@ -88,6 +92,7 @@ describe('#1049 Phase D — conflictPolicy parameter validation', () => { let app; beforeEach(async () => { + process.env.DEFAULT_OWNER_USER_ID = AUTH_HEADERS['x-test-session-user']; app = Fastify({ logger: false }); app.addHook('preHandler', async (request) => { const raw = request.headers['x-test-session-user']; @@ -101,6 +106,8 @@ describe('#1049 Phase D — conflictPolicy parameter validation', () => { afterEach(async () => { await app?.close(); + if (ORIGINAL_OWNER_USER_ID === undefined) delete process.env.DEFAULT_OWNER_USER_ID; + else process.env.DEFAULT_OWNER_USER_ID = ORIGINAL_OWNER_USER_ID; }); it('accepts conflictPolicy use-global without error', async () => { diff --git a/packages/api/test/opencode-mcp-isolation.test.js b/packages/api/test/opencode-mcp-isolation.test.js index 7828674fae..c7bfb5a34f 100644 --- a/packages/api/test/opencode-mcp-isolation.test.js +++ b/packages/api/test/opencode-mcp-isolation.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, mock, test } from 'node:test'; import { OpenCodeAgentService } from '../dist/domains/cats/services/agents/providers/OpenCodeAgentService.js'; +import { probeOpenCodeAutoApproveSupport } from '../dist/domains/cats/services/agents/providers/opencode-auto-approval.js'; import { generateOpenCodeConfig } from '../dist/domains/cats/services/agents/providers/opencode-config-template.js'; import { collect, createMockProcess, emitOpenCodeEvents } from './helpers/opencode-test-helpers.js'; @@ -23,6 +27,28 @@ const STEP_FINISH = { part: { type: 'step-finish', reason: 'stop', cost: 0.01, tokens: { total: 5000 } }, }; +function createHiddenAliasOpenCodeCli() { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-hidden-opencode-cli-')); + const file = join(dir, 'opencode'); + writeFileSync( + file, + `#!/bin/sh +if [ "$1" = "run" ] && [ "$2" = "--help" ]; then + echo "opencode run [message..]" + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "--dangerously-skip-permissions" ] && [ "$3" = "--help" ]; then + echo "opencode run [message..]" + exit 0 +fi +echo "unknown option" >&2 +exit 1 +`, + ); + chmodSync(file, 0o755); + return file; +} + async function invokeOpenCode(invokeOptions = {}, serviceOptions = {}) { const proc = createMockProcess(); const spawnFn = mock.fn(() => proc); @@ -231,37 +257,91 @@ describe('OpenCode headless permission mode', () => { ); }); - test('opencode auto-approval probe rejects CLIs without --auto support', async () => { + test('opencode auto-approval probe continues without default flag when no known flag is available', async () => { const { messages, spawnFn } = await invokeOpenCode( + {}, + { + autoApproveProbeFn: async () => ({}), + }, + ); + + assert.equal(spawnFn.mock.calls.length, 1, 'must launch opencode run even when no auto flag is available'); + assert.ok( + messages.some((message) => message.type === 'text'), + 'must stream the opencode result', + ); + const args = spawnFn.mock.calls[0].arguments[1]; + assert.equal(args.filter((arg) => arg === '--auto').length, 0, 'must not inject unsupported --auto'); + assert.equal( + args.filter((arg) => arg === '--dangerously-skip-permissions' || arg === '--yolo').length, + 0, + 'must not inject a legacy alias unless the probe selected one', + ); + }); + + test('opencode auto-approval probe injects selected legacy alias', async () => { + const args = await invokeOpenCodeAndCaptureArgs( {}, { autoApproveProbeFn: async () => ({ - supported: false, - message: 'OpenCode 版本过低,不支持 --auto 自动审批;请升级 opencode-ai 到 >= 1.17.12 后重试。', + approvalFlag: '--dangerously-skip-permissions', }), }, ); - assert.equal(spawnFn.mock.calls.length, 0, 'must not launch opencode run when --auto is unsupported'); - const error = messages.find((message) => message.type === 'error'); - assert.ok(error, 'must yield an error message'); - assert.match(error.error, /升级 opencode-ai 到 >= 1\.17\.12/); + assert.ok(args.includes('--dangerously-skip-permissions'), 'must inject the selected legacy alias'); + assert.equal(args.filter((arg) => arg === '--auto').length, 0, 'must not inject --auto when legacy alias wins'); }); - test('opencode auto-approval probe failure surfaces upgrade guidance', async () => { - const { messages, spawnFn } = await invokeOpenCode( + test('opencode auto-approval probe injects --auto when selected', async () => { + const args = await invokeOpenCodeAndCaptureArgs( {}, { autoApproveProbeFn: async () => ({ - supported: false, - message: '无法确认 OpenCode 是否支持 --auto 自动审批;请升级 opencode-ai 到 >= 1.17.12 后重试。', + approvalFlag: '--auto', }), }, ); - assert.equal(spawnFn.mock.calls.length, 0, 'must not launch opencode run when --auto support is unknown'); - const error = messages.find((message) => message.type === 'error'); - assert.ok(error, 'must yield an error message'); - assert.match(error.error, /无法确认 OpenCode 是否支持 --auto/); + assert.ok(args.includes('--auto'), 'must inject --auto when the probe selects it'); + assert.equal(args.filter((arg) => arg === '--auto').length, 1, 'must inject --auto exactly once'); + }); + + test('opencode auto-approval probe detects hidden legacy aliases', async () => { + const command = createHiddenAliasOpenCodeCli(); + + const result = await probeOpenCodeAutoApproveSupport(command); + + assert.equal(result.approvalFlag, '--dangerously-skip-permissions'); + }); + + test('opencode auto-approval probe retries after transient warning result', async () => { + const procs = [createMockProcess(), createMockProcess()]; + let spawnIndex = 0; + const spawnFn = mock.fn(() => procs[spawnIndex++]); + let probeAttempts = 0; + const service = new OpenCodeAgentService({ + catId: 'opencode', + spawnFn, + model: 'claude-haiku-4-5', + autoApproveProbeFn: async () => { + probeAttempts++; + return probeAttempts === 1 ? { warning: 'transient probe failure' } : { approvalFlag: '--auto' }; + }, + }); + + const firstInvocation = collect(service.invoke('Test')); + emitOpenCodeEvents(procs[0], [STEP_START, TEXT_RESPONSE, STEP_FINISH]); + await firstInvocation; + + const secondInvocation = collect(service.invoke('Test')); + emitOpenCodeEvents(procs[1], [STEP_START, TEXT_RESPONSE, STEP_FINISH]); + await secondInvocation; + + const firstArgs = spawnFn.mock.calls[0].arguments[1]; + const secondArgs = spawnFn.mock.calls[1].arguments[1]; + assert.equal(firstArgs.filter((arg) => arg === '--auto').length, 0, 'first transient warning omits default flag'); + assert.equal(probeAttempts, 2, 'transient warning results must not be cached'); + assert.ok(secondArgs.includes('--auto'), 'second invocation must retry and inject --auto when probe succeeds'); }); }); From 502d3fb3561d6f3370be2c7b56d0f5b932854a1d Mon Sep 17 00:00:00 2001 From: Lysander Su <773678591@qq.com> Date: Wed, 8 Jul 2026 16:50:47 +0800 Subject: [PATCH 02/15] fix(opencode): recognize GLM v4 provider endpoints (#1116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Zhipu/GLM OpenAI-compatible endpoints are versioned at /api/paas/v4 (and coding /api/coding/paas/v4), so provider setup must not display or regress into /v4/v1/chat/completions. Register zhipu/glm as first-class OpenCode provider suggestions and lock runtime baseUrl passthrough with regression coverage. Source: cat-cafe d1692107d0fc294d328af0c05ac1565839f1c5b7 [砚砚/GPT-5.5🐾] Co-authored-by: MaineCoon-GPT-5.5 <26771442+zts212653@users.noreply.github.com> --- packages/api/test/invoke-single-cat.test.js | 86 +++++++++++++++++++ .../hub-cat-editor-oc-providers.test.ts | 13 ++- .../components/hub-cat-editor.sections.tsx | 2 + 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 39498aee88..e1052077bd 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -5527,6 +5527,92 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await assert.rejects(readFile(seenConfigPath, 'utf-8')); }); + for (const { label, baseUrl } of [ + { label: 'GLM v4', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, + { label: 'GLM Coding Plan v4', baseUrl: 'https://api.z.ai/api/coding/paas/v4' }, + ]) { + it(`clowder-ai#1113: ${label} OpenCode binding preserves versioned baseUrl`, async () => { + const { createProviderProfile } = await import('./helpers/create-test-account.js'); + const root = await mkdtemp(join(tmpdir(), 'clowder1113-opencode-glm-v4-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; + const apiDir = join(root, 'packages', 'api'); + await mkdir(apiDir, { recursive: true }); + await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + + const customProfile = await createProviderProfile(root, { + provider: 'openai', + name: 'zhipu-api', + mode: 'api_key', + authType: 'api_key', + protocol: 'openai', + baseUrl, + apiKey: 'sk-zhipu-key', + models: ['zhipu/glm-4.6v'], + setActive: false, + }); + + const registrySnapshot = catRegistry.getAllConfigs(); + const originalConfig = catRegistry.tryGet('opencode')?.config; + assert.ok(originalConfig, 'opencode config should exist in registry'); + const boundCatId = `opencode-zhipu-${label.toLowerCase().replace(/\W+/g, '-')}`; + catRegistry.register(boundCatId, { + ...originalConfig, + id: boundCatId, + mentionPatterns: [`@${boundCatId}`], + clientId: 'opencode', + accountRef: customProfile.id, + defaultModel: 'zhipu/glm-4.6v', + provider: 'zhipu', + }); + + const optionsSeen = []; + let seenConfigPath; + let seenRuntimeConfig; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + seenConfigPath = options?.callbackEnv?.OPENCODE_CONFIG; + assert.ok(seenConfigPath, 'GLM OpenCode binding should receive OPENCODE_CONFIG'); + seenRuntimeConfig = JSON.parse(await readFile(seenConfigPath, 'utf-8')); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + + const deps = makeDeps(); + const previousCwd = process.cwd(); + try { + process.chdir(apiDir); + const messages = await collect( + invokeSingleCat(deps, { + catId: boundCatId, + service, + prompt: 'test GLM v4 routing', + userId: 'user-clowder1113-opencode-glm-v4', + threadId: 'thread-clowder1113-opencode-glm-v4', + isLastCat: true, + }), + ); + assert.ok(messages.some((m) => m.type === 'done')); + } finally { + process.chdir(previousCwd); + catRegistry.reset(); + for (const [id, config] of Object.entries(registrySnapshot)) { + catRegistry.register(id, config); + } + await rmWithRetry(root); + } + + const callbackEnv = optionsSeen[0]?.callbackEnv ?? {}; + assert.equal(callbackEnv.CAT_CAFE_OC_API_KEY, 'sk-zhipu-key'); + assert.equal(callbackEnv.CAT_CAFE_OC_BASE_URL, baseUrl); + assert.equal(seenRuntimeConfig?.model, 'zhipu/glm-4.6v'); + assert.equal(seenRuntimeConfig?.provider?.zhipu?.npm, '@ai-sdk/openai-compatible'); + assert.deepStrictEqual(seenRuntimeConfig?.provider?.zhipu?.models, { 'glm-4.6v': { name: 'glm-4.6v' } }); + await assert.rejects(readFile(seenConfigPath, 'utf-8')); + }); + } + it('clowder-ai#223: bare model + provider assembles composite model for custom provider routing', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-oc-bare-model-')); diff --git a/packages/web/src/components/__tests__/hub-cat-editor-oc-providers.test.ts b/packages/web/src/components/__tests__/hub-cat-editor-oc-providers.test.ts index 079c5a6641..e52a4dc528 100644 --- a/packages/web/src/components/__tests__/hub-cat-editor-oc-providers.test.ts +++ b/packages/web/src/components/__tests__/hub-cat-editor-oc-providers.test.ts @@ -26,7 +26,7 @@ describe('KNOWN_OC_PROVIDERS datalist suggestions', () => { }); it('includes core provider names', () => { - for (const name of ['anthropic', 'openai', 'google', 'openrouter']) { + for (const name of ['anthropic', 'openai', 'google', 'openrouter', 'zhipu', 'glm']) { expect(KNOWN_OC_PROVIDERS).toContain(name); } }); @@ -36,6 +36,7 @@ describe('KNOWN_OC_PROVIDERS datalist suggestions', () => { expect(resolveOpenCodeEndpoint('anthropic')).toBe('/v1/messages'); expect(resolveOpenCodeEndpoint('google')).toBe('/models/{model}:generateContent'); expect(resolveOpenCodeEndpoint('maas')).toBe('/v1/chat/completions'); + expect(resolveOpenCodeEndpoint('zhipu')).toBe('/v1/chat/completions'); }); }); @@ -50,6 +51,16 @@ describe('buildCallHint — API version URL display (#886)', () => { expect(hint?.url).toBe('https://maas-api.cn-huabei-1.xf-yun.com/v2/chat/completions'); }); + it('clowder-ai#1113: GLM v4 endpoint produces /v4/chat/completions, not /v4/v1/chat/completions', () => { + const hint = buildCallHint('opencode', mkProfile('https://open.bigmodel.cn/api/paas/v4'), 'glm-4.6v', 'zhipu'); + expect(hint?.url).toBe('https://open.bigmodel.cn/api/paas/v4/chat/completions'); + }); + + it('clowder-ai#1113: GLM Coding Plan v4 endpoint preserves the coding prefix', () => { + const hint = buildCallHint('opencode', mkProfile('https://api.z.ai/api/coding/paas/v4'), 'glm-4.6v', 'zhipu'); + expect(hint?.url).toBe('https://api.z.ai/api/coding/paas/v4/chat/completions'); + }); + it('base without version appends full /v1 suffix', () => { const hint = buildCallHint('opencode', mkProfile('https://api.example.com'), 'gpt-4', 'openai'); expect(hint?.url).toBe('https://api.example.com/v1/chat/completions'); diff --git a/packages/web/src/components/hub-cat-editor.sections.tsx b/packages/web/src/components/hub-cat-editor.sections.tsx index 325f2a4381..9b12f4ac60 100644 --- a/packages/web/src/components/hub-cat-editor.sections.tsx +++ b/packages/web/src/components/hub-cat-editor.sections.tsx @@ -250,6 +250,8 @@ export const KNOWN_OC_PROVIDERS = [ 'google', 'azure', 'deepseek', + 'zhipu', + 'glm', ]; /** Merge well-known providers with any prefixes extracted from model strings like "openai/gpt-5.4". */ From 7062de8439485d5628b709e9aae5119dc52b491e Mon Sep 17 00:00:00 2001 From: Li Ang <89139249+Ryann-Leee@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:25:14 +0800 Subject: [PATCH 03/15] fix(f24): raise stale CLI context-window reports to known-model floors (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Claude CLI <=2.1.177 reports contextWindow=200000 for claude-fable-5 (native 1M), and the CLI report outranks the fallback table in invoke-single-cat -- so all four fable5 variants sealed budget_exhausted at ~190K, wasting 80% of the real window. Production proof: sessions 59a48070/6b8d4b5f (thread_mraghcf19yl6ukzu) consumed 303K/307K input tokens in the very turns that claimed windowTokens=200K (fillRatio 1.0), truncating the same co-creator answer twice. How: KNOWN_MIN_CONTEXT_WINDOWS floors (claude-fable-5=1M; any [1m]-suffixed model=1M -- the suffix is the CLI's own 'run at 1M' directive) applied via resolveContextWindow() as max(reported ?? fallback, floor). Never shrinks a CLI report; becomes a no-op once the CLI catches up. Real fix is the CLI upgrade (2.1.177 -> 2.1.204, separate ops action); this is defense-in-depth. [宪宪/Fable5🐾] Co-authored-by: Claude Fable 5 --- .../api/src/config/context-window-sizes.ts | 92 +++++++++++++++---- .../agents/invocation/invoke-single-cat.ts | 13 +-- .../api/test/context-window-sizes.test.js | 65 +++++++++++++ 3 files changed, 147 insertions(+), 23 deletions(-) diff --git a/packages/api/src/config/context-window-sizes.ts b/packages/api/src/config/context-window-sizes.ts index 1299503e3f..5b32947d82 100644 --- a/packages/api/src/config/context-window-sizes.ts +++ b/packages/api/src/config/context-window-sizes.ts @@ -13,6 +13,11 @@ export const CONTEXT_WINDOW_SIZES: Record = { 'claude-opus-4-6': 200_000, 'claude-sonnet-4-5': 200_000, 'claude-haiku-4-5': 200_000, + // Fable 5: native 1M context — the maximum is also the default (no [1m] + // suffix needed). Also listed in KNOWN_MIN_CONTEXT_WINDOWS below because + // stale CLIs (≤2.1.177) mis-REPORT it as 200K, which this table alone + // cannot fix (CLI report outranks the fallback table). + 'claude-fable-5': 1_000_000, // Codex/GPT 'gpt-5.3': 128_000, 'gpt-5.2': 128_000, @@ -49,25 +54,78 @@ export const CONTEXT_WINDOW_SIZES: Record = { */ export const OPENCODE_DEFAULT_CONTEXT_WINDOW = 128_000; -export function getContextWindowFallback(model: string): number | undefined { - // Normalize provider-prefixed model IDs before lookup. The account routing - // path in invoke-single-cat sets `callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE` - // to a `safeProvider/model` form (see L1459 `safeProvider/safeModel`), and - // OpenCodeAgentService propagates that prefixed string as `metadata.model`. - // Without normalization, lookups like `anthropic/claude-opus-4-6` or - // `openai-compat/gpt-5.3` would miss the table entirely → no windowSize → - // F24 context_health silently skipped → opencode handoff (clowder#915) - // bypassed in production. (clowder#915 R2 cloud P1) - // - // Use lastIndexOf to handle multi-segment prefixes like `openai-compat/x/y` - // (defensive — current code emits at most one slash, but the cost is the - // same and we don't want to be the next migration's footgun). +// Normalize provider-prefixed model IDs before lookup. The account routing +// path in invoke-single-cat sets `callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE` +// to a `safeProvider/model` form (see L1459 `safeProvider/safeModel`), and +// OpenCodeAgentService propagates that prefixed string as `metadata.model`. +// Without normalization, lookups like `anthropic/claude-opus-4-6` or +// `openai-compat/gpt-5.3` would miss the table entirely → no windowSize → +// F24 context_health silently skipped → opencode handoff (clowder#915) +// bypassed in production. (clowder#915 R2 cloud P1) +// +// Use lastIndexOf to handle multi-segment prefixes like `openai-compat/x/y` +// (defensive — current code emits at most one slash, but the cost is the +// same and we don't want to be the next migration's footgun). +function stripProviderPrefix(model: string): string { const slashAt = model.lastIndexOf('/'); - const bare = slashAt >= 0 ? model.slice(slashAt + 1) : model; - if (CONTEXT_WINDOW_SIZES[bare]) return CONTEXT_WINDOW_SIZES[bare]; - // Try prefix match (e.g. 'claude-opus-4-6-20260101' matches 'claude-opus-4-6') - for (const [key, value] of Object.entries(CONTEXT_WINDOW_SIZES)) { + return slashAt >= 0 ? model.slice(slashAt + 1) : model; +} + +function lookupWithPrefixMatch(table: Record, bare: string): number | undefined { + if (table[bare]) return table[bare]; + // Prefix match (e.g. 'claude-opus-4-6-20260101' matches 'claude-opus-4-6') + for (const [key, value] of Object.entries(table)) { if (bare.startsWith(key)) return value; } return undefined; } + +export function getContextWindowFallback(model: string): number | undefined { + return lookupWithPrefixMatch(CONTEXT_WINDOW_SIZES, stripProviderPrefix(model)); +} + +/** + * Known-minimum context windows — authoritative floors used to correct + * STALE CLI-reported window sizes, applied as `max(reported, floor)`. + * + * Why this exists (F24 follow-up): the Claude CLI reports + * `modelUsage[*].contextWindow` and invoke-single-cat trusts that report + * FIRST — so a stale CLI ships a stale window and the fallback table + * above can never correct it. Proven in production: CLI 2.1.177 reported + * 200_000 for `claude-fable-5` (native 1M) while the very same turn + * consumed 303K input tokens without error; auto-seal fired at + * "fillRatio 1.0" with 80% of the real window unused (sessions + * 59a48070 / 6b8d4b5f, thread_mraghcf19yl6ukzu, 2026-07-08). + * + * Rules: + * - `[1m]` suffix: Claude Code's own "run at 1M context" directive — if + * the CLI accepted the model string, 1M IS the session window. + * - Table entries: ONLY models whose window we know from official specs + * with certainty. An over-estimate defeats auto-seal (the session + * would drift into CLI auto-compact instead of sealing with a clean + * handoff), so keep this list conservative. + * - `max()` semantics: never shrinks a CLI report. Once the CLI catches + * up (2.1.204+ presumably reports 1M), the floor becomes a no-op. + */ +const KNOWN_MIN_CONTEXT_WINDOWS: Record = { + 'claude-fable-5': 1_000_000, +}; + +export function getKnownMinContextWindow(model: string): number | undefined { + const bare = stripProviderPrefix(model); + if (bare.endsWith('[1m]')) return 1_000_000; + return lookupWithPrefixMatch(KNOWN_MIN_CONTEXT_WINDOWS, bare); +} + +/** + * Single resolution point for "how big is this session's context window": + * CLI-reported value → fallback table, then raised to any known + * authoritative floor. Callers needing a provider-specific last resort + * (e.g. opencode's 128K default) apply it AFTER this returns undefined. + */ +export function resolveContextWindow(reported: number | undefined, model: string): number | undefined { + const base = reported ?? getContextWindowFallback(model); + const floor = getKnownMinContextWindow(model); + if (base != null && floor != null) return Math.max(base, floor); + return base ?? floor; +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 49617268fe..0fdd5171d0 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -32,10 +32,7 @@ import { resolveBoundAccountRefForCat } from '../../../../../config/cat-account- import { isSessionChainEnabled } from '../../../../../config/cat-config-loader.js'; import { buildCatGitIdentityEnv } from '../../../../../config/cat-git-identity.js'; import { getCatModel } from '../../../../../config/cat-models.js'; -import { - getContextWindowFallback, - OPENCODE_DEFAULT_CONTEXT_WINDOW, -} from '../../../../../config/context-window-sizes.js'; +import { OPENCODE_DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from '../../../../../config/context-window-sizes.js'; import { getSessionStrategy, shouldTakeAction } from '../../../../../config/session-strategy.js'; import { assertSafeTestConfigRoot } from '../../../../../config/test-config-write-guard.js'; import { capturePromptIfEnabled } from '../../../../../infrastructure/debug/prompt-capture-bridge.js'; @@ -2141,9 +2138,13 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // actually targets). Crucially this is LAST so known opencode // models like the default claude-opus-4-6 get their precise 200k // from the table, NOT the 128k conservative default. + // F24 known-min floor: tiers 1-2 are additionally raised to + // max(value, known floor) inside resolveContextWindow — a stale + // CLI (≤2.1.177) reports 200K for claude-fable-5 (native 1M), + // and tier 1 outranking tier 2 meant the wrong value always won: + // sessions sealed budget_exhausted with 80% of the window unused. const windowSize = - msg.metadata.usage.contextWindowSize ?? - getContextWindowFallback(msg.metadata.model ?? '') ?? + resolveContextWindow(msg.metadata.usage.contextWindowSize, msg.metadata.model ?? '') ?? (msg.metadata.provider === 'opencode' ? OPENCODE_DEFAULT_CONTEXT_WINDOW : undefined); const usedFrom = msg.metadata.usage.lastTurnInputTokens != null diff --git a/packages/api/test/context-window-sizes.test.js b/packages/api/test/context-window-sizes.test.js index 905de02a27..cf262532f9 100644 --- a/packages/api/test/context-window-sizes.test.js +++ b/packages/api/test/context-window-sizes.test.js @@ -85,3 +85,68 @@ describe('getContextWindowFallback', () => { assert.equal(getContextWindowFallback('o3'), 200_000); }); }); + +// F24 known-min floor: stale CLI window reports (e.g. CLI 2.1.177 reporting +// 200K for claude-fable-5, natively 1M) outrank the fallback table, so the +// table alone can't correct them. Floors are applied as max(reported, floor). +// Production evidence: sessions 59a48070/6b8d4b5f consumed 303K/307K input +// tokens in a single turn while windowTokens claimed 200000 → premature +// budget_exhausted seal with 80% of the real window unused. +describe('getKnownMinContextWindow / resolveContextWindow', () => { + let getContextWindowFallback; + let getKnownMinContextWindow; + let resolveContextWindow; + + test('setup', async () => { + const mod = await import('../dist/config/context-window-sizes.js'); + getContextWindowFallback = mod.getContextWindowFallback; + getKnownMinContextWindow = mod.getKnownMinContextWindow; + resolveContextWindow = mod.resolveContextWindow; + }); + + test('knows claude-fable-5 native 1M window (exact, versioned, provider-prefixed)', async () => { + assert.equal(getKnownMinContextWindow('claude-fable-5'), 1_000_000); + assert.equal(getKnownMinContextWindow('claude-fable-5-20260601'), 1_000_000); + assert.equal(getKnownMinContextWindow('anthropic/claude-fable-5'), 1_000_000); + }); + + test('treats the CLI [1m] directive as a 1M floor', async () => { + assert.equal(getKnownMinContextWindow('claude-opus-4-6[1m]'), 1_000_000); + assert.equal(getKnownMinContextWindow('claude-opus-4-8[1m]'), 1_000_000); + assert.equal(getKnownMinContextWindow('claude-sonnet-4-6[1m]'), 1_000_000); + }); + + test('returns undefined when no authoritative floor is known', async () => { + assert.equal(getKnownMinContextWindow('claude-opus-4-6'), undefined); + assert.equal(getKnownMinContextWindow('claude-sonnet-4-5'), undefined); + assert.equal(getKnownMinContextWindow('gpt-5.3'), undefined); + assert.equal(getKnownMinContextWindow(''), undefined); + }); + + test('fallback table gained claude-fable-5 (non-Claude-CLI provider paths)', async () => { + assert.equal(getContextWindowFallback('claude-fable-5'), 1_000_000); + }); + + test('resolveContextWindow corrects a stale CLI report upward (the fable-5 seal bug)', async () => { + assert.equal(resolveContextWindow(200_000, 'claude-fable-5'), 1_000_000); + assert.equal(resolveContextWindow(200_000, 'claude-opus-4-8[1m]'), 1_000_000); + }); + + test('resolveContextWindow never shrinks a CLI report', async () => { + // CLI catches up → floor is a no-op + assert.equal(resolveContextWindow(1_000_000, 'claude-fable-5'), 1_000_000); + // window grows beyond our floor → trust the CLI + assert.equal(resolveContextWindow(2_000_000, 'claude-fable-5'), 2_000_000); + // no floor known → passthrough + assert.equal(resolveContextWindow(200_000, 'claude-opus-4-6'), 200_000); + }); + + test('resolveContextWindow falls back to the table; floor still applies', async () => { + assert.equal(resolveContextWindow(undefined, 'claude-opus-4-6'), 200_000); + // Without the floor, the [1m] form would prefix-match to the bare + // model's 200K table entry — the floor corrects it to 1M. + assert.equal(resolveContextWindow(undefined, 'claude-opus-4-6[1m]'), 1_000_000); + assert.equal(resolveContextWindow(undefined, 'claude-fable-5'), 1_000_000); + assert.equal(resolveContextWindow(undefined, 'unknown-model'), undefined); + }); +}); From d11347d53d3c4194f7856849d355b863194f5b5b Mon Sep 17 00:00:00 2001 From: Li Ang <89139249+Ryann-Leee@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:15 +0800 Subject: [PATCH 04/15] fix(pr-tracking): deliver terminal lifecycle notification + wake owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: PR tracking was marking merged/closed PR tasks done without an owner-visible terminal lifecycle delivery path, and source-first review found the review-feedback close path could mark tracking done before CI lifecycle recovery recorded the terminal state. The source counterpart has landed, this public PR has been synced to c4f000fe56b53d2ea473c34a8ac9cad39d09ac43, and GitHub CI is green.\n\n[砚砚/GPT-5.5🐾] --- .../infrastructure/email/CiCdCheckTaskSpec.ts | 98 +++++- .../src/infrastructure/email/CiCdRouter.ts | 323 +++++++++++------- .../email/ReviewFeedbackTaskSpec.ts | 25 ++ .../email/ci-message-content.ts | 63 ++++ packages/api/test/cicd-router.test.js | 235 ++++++++++++- .../test/scheduler/cicd-check-spec.test.js | 108 +++++- .../scheduler/review-feedback-spec.test.js | 8 + packages/shared/src/types/task.ts | 4 +- 8 files changed, 710 insertions(+), 154 deletions(-) create mode 100644 packages/api/src/infrastructure/email/ci-message-content.ts diff --git a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts index 3bdcb4b255..b19d57b654 100644 --- a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts @@ -7,7 +7,7 @@ * Execute: fetchPrCiStatus → route → conditional trigger. * - CI fail → wake (urgent) — both intents. * - CI pass → gated by the tracked PR's wake intent (F140): - * intent='review' (default) → silent (review-wait noise; CiCdRouter already posted the message). + * intent='review' (default) → state-only silent (review-wait noise; no connector message). * intent='merge' → wake → merge-gate (the cat is waiting on CI-green to merge). * Intent is an explicit per-task declaration (set at register_pr_tracking, updated by re-register), * NOT inferred from approval state or repo type — a private PR can be 'merge', an open-source PR @@ -17,7 +17,7 @@ import type { CatId, TaskItem } from '@cat-cafe/shared'; import { parsePrSubjectKey } from '@cat-cafe/shared'; import type { ITaskStore } from '../../domains/cats/services/stores/ports/TaskStore.js'; import type { ExecuteContext, TaskSpec_P1 } from '../scheduler/types.js'; -import type { CiCdRouter, CiPollResult } from './CiCdRouter.js'; +import type { CiCdRouter, CiPollResult, CiRouteResult } from './CiCdRouter.js'; import type { ConnectorInvokeTrigger, ConnectorTriggerPolicy } from './ConnectorInvokeTrigger.js'; import { fetchPrCiStatus } from './ci-status-fetcher.js'; @@ -41,6 +41,55 @@ export interface CiCdCheckTaskSpecOptions { readonly pollIntervalMs?: number; /** F202-2B: Override task ID for plugin-scoped schedule instances */ readonly id?: string; + /** F167 Phase Q: retire matching hold_ball timers once structured CI status is delivered. */ + readonly holdLifecycle?: { + retireSatisfiedWait(event: { + threadId: string; + subjectKey: string; + expectedSignalKey: 'ci_complete'; + sourceKind: 'ci_check'; + sourceMessageId?: string; + }): void | Promise; + }; +} + +/** + * PR terminal state (merged/closed) -> wake the owner for follow-up, both intents. + * intent=merge: the merge is the awaited outcome. intent=review: the PR is gone. + * Fires exactly once in production: CiCdRouter persists ci.prState and the gate filters completed lifecycle tasks. + */ +function triggerLifecycleWake( + opts: CiCdCheckTaskSpecOptions, + invokeTrigger: ConnectorInvokeTrigger, + signal: CiCdCheckSignal, + routeResult: Extract, +): void { + const policy: ConnectorTriggerPolicy = { + priority: 'normal', + reason: routeResult.prState === 'merged' ? 'github_pr_merged' : 'github_pr_closed', + sourceCategory: 'ci', + }; + void invokeTrigger + .trigger( + routeResult.threadId, + routeResult.catId as CatId, + signal.task.userId ?? '', + routeResult.content, + routeResult.messageId, + undefined, + policy, + ) + .catch((err) => opts.log.warn({ err }, '[cicd-check] lifecycle trigger failed (best-effort)')); + opts.log.info(`[cicd-check] PR ${routeResult.prState} -> wake ${routeResult.catId} (terminal lifecycle)`); +} + +function needsCiLifecycleRecovery(task: TaskItem): boolean { + const reviewTerminalState = task.automationState?.review?.prState; + return ( + task.status === 'done' && + (reviewTerminalState === 'merged' || reviewTerminalState === 'closed') && + !task.automationState?.ci?.prState + ); } export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpec_P1 { @@ -52,9 +101,13 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe trigger: { type: 'interval', ms: opts.pollIntervalMs ?? 60_000 }, admission: { async gate() { - // #320: Read from unified TaskStore — exclude done tasks (PR merged/closed) + // #320: Read from unified TaskStore — exclude done tasks after CI lifecycle is complete. + // Review feedback can observe terminal PR state first; keep those done tasks + // reachable until CiCdRouter delivers/records the CI lifecycle marker. const allTasks = await opts.taskStore.listByKind('pr_tracking'); - const active = allTasks.filter((t) => t.status !== 'done' && t.automationState?.ci?.enabled !== false); + const active = allTasks.filter( + (t) => (t.status !== 'done' || needsCiLifecycleRecovery(t)) && t.automationState?.ci?.enabled !== false, + ); if (active.length === 0) { return { run: false, reason: 'no active tracked PRs' }; @@ -80,15 +133,39 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe run: { overlap: 'skip', timeoutMs: 30_000, - async execute(signal: CiCdCheckSignal, _subjectKey: string, _ctx: ExecuteContext) { + async execute(signal: CiCdCheckSignal, subjectKey: string, _ctx: ExecuteContext) { const pollResult = await fetchPrStatus(signal.repoFullName, signal.prNumber); if (!pollResult) return; const routeResult = await opts.cicdRouter.route(pollResult); - if (routeResult.kind !== 'notified' || !opts.invokeTrigger) return; + if (!opts.invokeTrigger) return; + + const retireSatisfiedCiHold = async (threadId: string, sourceMessageId: string) => { + if (!opts.holdLifecycle) return; + try { + await opts.holdLifecycle.retireSatisfiedWait({ + threadId, + subjectKey, + expectedSignalKey: 'ci_complete', + sourceKind: 'ci_check', + sourceMessageId, + }); + } catch (err) { + opts.log.warn({ err, subjectKey }, '[cicd-check] hold lifecycle retirement failed (best-effort)'); + } + }; + + if (routeResult.kind === 'lifecycle') { + await retireSatisfiedCiHold(routeResult.threadId, routeResult.messageId); + triggerLifecycleWake(opts, opts.invokeTrigger, signal, routeResult); + return; + } + + if (routeResult.kind !== 'notified') return; // CI fail → always wake (urgent, must fix) — independent of intent. if (routeResult.bucket === 'fail') { + await retireSatisfiedCiHold(routeResult.threadId, routeResult.messageId); const policy: ConnectorTriggerPolicy = { priority: 'urgent', reason: 'github_ci_failure', @@ -110,17 +187,16 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe } // CI pass → gated by the tracked PR's wake intent (F140 Phase C partial revert). - // 'review' (default): the cat is waiting on review feedback → CI-pass is noise. CiCdRouter has - // already posted the "CI 通过" thread message (visible whenever the cat looks), so stay silent. + // 'review' (default): the cat is waiting on review feedback → CI-pass is noise. + // CiCdRouter should record state without posting a connector message, so stay silent. // 'merge': the cat is waiting on CI-green to merge → CI-pass is the action signal → merge-gate. const intent = signal.task.automationState?.intent ?? 'review'; if (intent !== 'merge') { - opts.log.info( - `[cicd-check] CI pass for ${routeResult.catId} — silent (intent=${intent}; thread message only)`, - ); + opts.log.info(`[cicd-check] CI pass for ${routeResult.catId} — silent (intent=${intent}; state-only)`); return; } + await retireSatisfiedCiHold(routeResult.threadId, routeResult.messageId); const policy: ConnectorTriggerPolicy = { priority: 'normal', reason: 'github_ci_pass', diff --git a/packages/api/src/infrastructure/email/CiCdRouter.ts b/packages/api/src/infrastructure/email/CiCdRouter.ts index b4b6f4f16e..19f046aa8e 100644 --- a/packages/api/src/infrastructure/email/CiCdRouter.ts +++ b/packages/api/src/infrastructure/email/CiCdRouter.ts @@ -4,13 +4,17 @@ * #320: Reads from unified TaskStore instead of PrTrackingStore. */ import type { ConnectorSource } from '@cat-cafe/shared'; -import { parsePrSubjectKey, prSubjectKey } from '@cat-cafe/shared'; +import { prSubjectKey } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; import type { ITaskStore } from '../../domains/cats/services/stores/ports/TaskStore.js'; import type { ICommunityEventLog } from '../../domains/community/CommunityEventLog.js'; +import { buildCiMessageContent, buildLifecycleMessageContent } from './ci-message-content.js'; import type { ConnectorDeliveryDeps } from './deliver-connector-message.js'; import { deliverConnectorMessage } from './deliver-connector-message.js'; +// Re-export for existing import sites (index.ts, tests). +export { buildCiMessageContent, buildLifecycleMessageContent }; + /** Minimal projector interface for optional DI — avoids importing concrete class. */ interface ICommunityProjectorMin { apply(event: Parameters[0]): Promise; @@ -37,9 +41,42 @@ export interface CiPollResult { export type CiRouteResult = | { kind: 'notified'; threadId: string; catId: string; messageId: string; bucket: CiBucket; content: string } + | { + kind: 'lifecycle'; + threadId: string; + catId: string; + messageId: string; + prState: 'merged' | 'closed'; + content: string; + } | { kind: 'deduped'; reason: string } | { kind: 'skipped'; reason: string }; +/** Subset of the tracked TaskItem fields the lifecycle-close path reads. */ +interface TrackedTaskLike { + readonly id: string; + readonly threadId: string; + readonly ownerCatId: string | null; + readonly userId?: string; + readonly title?: string; + readonly automationState?: { + readonly ci?: { readonly prState?: 'merged' | 'closed' }; + readonly trackingInstructions?: string; + }; +} + +function getConnectorDeliveryTarget(task: Pick): { + threadId: string; + userId: string; + catId: string; +} { + return { + threadId: task.threadId, + userId: task.userId ?? '', + catId: task.ownerCatId ?? '', + }; +} + export interface CiCdRouterOptions { readonly taskStore: ITaskStore; readonly deliveryDeps: ConnectorDeliveryDeps; @@ -85,6 +122,17 @@ export class CiCdRouter { return { kind: 'skipped', reason: `No tracking task for ${poll.repoFullName}#${poll.prNumber}` }; } + const terminalPrState = poll.prState === 'merged' || poll.prState === 'closed' ? poll.prState : null; + if (task.status === 'done') { + if (terminalPrState && !task.automationState?.ci?.prState) { + return this.closeLifecycle(poll, task, sk); + } + return { + kind: 'skipped', + reason: `Tracking task already processed for ${poll.repoFullName}#${poll.prNumber}`, + }; + } + if (task.automationState?.ci?.enabled === false) { if (!task.automationState.ci.skipNotified) { this.opts.notifySkip?.(task.threadId, 'ci_automation_disabled'); @@ -93,91 +141,8 @@ export class CiCdRouter { return { kind: 'skipped', reason: `CI tracking disabled for ${poll.repoFullName}#${poll.prNumber}` }; } - if (poll.prState === 'merged' || poll.prState === 'closed') { - // #320 KD-17: lifecycle close = mark task done (not delete) - // F200 AC-D2.3: persist prState so signal detection can distinguish merged vs closed - await taskStore.update(task.id, { status: 'done' }); - await taskStore.patchAutomationState(task.id, { ci: { prState: poll.prState } }); - log.info(`[CiCdRouter] PR ${poll.repoFullName}#${poll.prNumber} ${poll.prState} — task marked done`); - - // F192 Phase G: emit A1 world truth signal on merge only. - // 'closed' = PR abandoned without merge — NOT a code revert. - // Code reverts are separate git events (revert commits), not PR lifecycle. - if (poll.prState === 'merged' && this.opts.onPrLifecycle) { - try { - this.opts.onPrLifecycle({ - type: 'merge', - ref: `PR#${poll.prNumber}`, - outcome: 'success', - threadId: task.threadId, - }); - } catch (err) { - log.warn( - { err, repoFullName: poll.repoFullName, prNumber: poll.prNumber, threadId: task.threadId }, - '[CiCdRouter] onPrLifecycle callback failed (best-effort)', - ); - } - } - - // F208 AC-E2: distillation checkpoint on feat-phase-close (best-effort, merge only). - // CiCdRouter is the canonical first-detection point for merge — checkpoint MUST fire here - // because ReviewFeedbackTaskSpec gate filters done tasks and may miss. - // sourceId dedup makes double-fire from ReviewFeedbackTaskSpec safe. - if (poll.prState === 'merged' && this.opts.distillationCheckpoint) { - try { - // Extract featureId from trackingInstructions (prTitle not available here) - const featureSource = task.automationState?.trackingInstructions ?? task.title ?? ''; - const featureMatch = featureSource.match(/\b[Ff](\d{2,4})\b/); - const featureId = featureMatch ? `F${featureMatch[1]}` : undefined; - if (featureId) { - const phaseMatch = featureSource.match(/[Pp]hase\s+([A-Z])/i); - await this.opts.distillationCheckpoint.onFeatPhaseClose({ - prNumber: poll.prNumber, - repoFullName: poll.repoFullName, - authorCatId: (task.ownerCatId ?? 'unknown') as string, - threadId: task.threadId, - featureId, - phaseLabel: phaseMatch?.[1] ?? 'unknown', - }); - } - } catch { - log.warn( - `[CiCdRouter] distillation checkpoint (feat-phase-close) failed for ${poll.repoFullName}#${poll.prNumber}`, - ); - } - } - - // F168 Phase A P1-3: canonical community event emission. - // This is the first reliable detection point for PR lifecycle. - // sourceEventId = `lifecycle:${sk}:${prState}` — dedup-safe if ReviewFeedbackTaskSpec also fires. - if (this.opts.eventLog) { - try { - const eventKind = poll.prState === 'merged' ? 'pr.merged' : 'pr.closed'; - const communityEvent = { - sourceEventId: `lifecycle:${sk}:${poll.prState}`, - subjectKey: sk, - kind: eventKind as 'pr.merged' | 'pr.closed', - classification: 'state-changing' as const, - payload: { - prState: poll.prState, - repoFullName: poll.repoFullName, - prNumber: poll.prNumber, - }, - at: Date.now(), - }; - const { appended } = await this.opts.eventLog.append(communityEvent); - if (appended && this.opts.projector) { - await this.opts.projector.apply(communityEvent); - } - } catch (err) { - log.warn( - { err, repoFullName: poll.repoFullName, prNumber: poll.prNumber, subjectKey: sk }, - '[CiCdRouter] event log append failed (best-effort, spec §Task6)', - ); - } - } - - return { kind: 'skipped', reason: `PR ${poll.prState}` }; + if (terminalPrState) { + return this.closeLifecycle(poll, task, sk); } if (poll.aggregateBucket === 'pending') { @@ -192,9 +157,155 @@ export class CiCdRouter { return { kind: 'deduped', reason: `Already notified for ${fingerprint}` }; } + const intent = task.automationState?.intent ?? 'review'; + if (poll.aggregateBucket === 'pass' && intent !== 'merge') { + await taskStore.patchAutomationState(task.id, { + ci: { + headSha: poll.headSha, + lastFingerprint: fingerprint, + lastBucket: poll.aggregateBucket, + }, + }); + log.info(`[CiCdRouter] CI pass for ${poll.repoFullName}#${poll.prNumber} recorded silently (intent=${intent})`); + return { kind: 'skipped', reason: `CI pass silent for review intent (${poll.repoFullName}#${poll.prNumber})` }; + } + return this.deliver(poll, task, fingerprint); } + /** + * PR reached terminal state (merged/closed). + * Mark done first, then emit best-effort side effects and final owner-visible + * lifecycle notification. Delivery failure degrades to the previous silent close. + */ + private async closeLifecycle(poll: CiPollResult, task: TrackedTaskLike, sk: string): Promise { + const { taskStore, log } = this.opts; + const prState = poll.prState as 'merged' | 'closed'; + + // #320 KD-17: lifecycle close = mark task done (not delete) + // F200 AC-D2.3: persist prState so signal detection can distinguish merged vs closed + await taskStore.update(task.id, { status: 'done' }); + await taskStore.patchAutomationState(task.id, { ci: { prState } }); + log.info(`[CiCdRouter] PR ${poll.repoFullName}#${poll.prNumber} ${prState} — task marked done`); + + await this.emitTerminalSideEffects(poll, task, sk); + + try { + const content = buildLifecycleMessageContent(poll, task.automationState?.trackingInstructions); + const deliveryTarget = getConnectorDeliveryTarget(task); + const source: ConnectorSource = { + connector: 'github-ci', + label: 'GitHub CI/CD', + icon: 'github', + url: `https://github.com/${poll.repoFullName}/pull/${poll.prNumber}`, + }; + const delivered = await deliverConnectorMessage(this.opts.deliveryDeps, { + ...deliveryTarget, + content, + source, + }); + log.info( + `[CiCdRouter] PR ${prState} -> lifecycle notification to ${deliveryTarget.catId} in thread ${deliveryTarget.threadId}`, + ); + return { + kind: 'lifecycle', + threadId: deliveryTarget.threadId, + catId: deliveryTarget.catId, + messageId: delivered.messageId, + prState, + content, + }; + } catch (err) { + log.error( + { err, repoFullName: poll.repoFullName, prNumber: poll.prNumber, threadId: task.threadId }, + '[CiCdRouter] lifecycle delivery failed — task already done, no retry (degrades to silent close)', + ); + return { kind: 'skipped', reason: `PR ${prState} (lifecycle delivery failed)` }; + } + } + + /** Best-effort side effects on terminal close: A1 signal, distillation checkpoint, community event. */ + private async emitTerminalSideEffects(poll: CiPollResult, task: TrackedTaskLike, sk: string): Promise { + const { log } = this.opts; + + // F192 Phase G: emit A1 world truth signal on merge only. + // 'closed' = PR abandoned without merge — NOT a code revert. + // Code reverts are separate git events (revert commits), not PR lifecycle. + if (poll.prState === 'merged' && this.opts.onPrLifecycle) { + try { + this.opts.onPrLifecycle({ + type: 'merge', + ref: `PR#${poll.prNumber}`, + outcome: 'success', + threadId: task.threadId, + }); + } catch (err) { + log.warn( + { err, repoFullName: poll.repoFullName, prNumber: poll.prNumber, threadId: task.threadId }, + '[CiCdRouter] onPrLifecycle callback failed (best-effort)', + ); + } + } + + // F208 AC-E2: distillation checkpoint on feat-phase-close (best-effort, merge only). + // CiCdRouter is the canonical first-detection point for merge — checkpoint MUST fire here + // because ReviewFeedbackTaskSpec gate filters done tasks and may miss. + // sourceId dedup makes double-fire from ReviewFeedbackTaskSpec safe. + if (poll.prState === 'merged' && this.opts.distillationCheckpoint) { + try { + // Extract featureId from trackingInstructions (prTitle not available here) + const featureSource = task.automationState?.trackingInstructions ?? task.title ?? ''; + const featureMatch = featureSource.match(/\b[Ff](\d{2,4})\b/); + const featureId = featureMatch ? `F${featureMatch[1]}` : undefined; + if (featureId) { + const phaseMatch = featureSource.match(/[Pp]hase\s+([A-Z])/i); + await this.opts.distillationCheckpoint.onFeatPhaseClose({ + prNumber: poll.prNumber, + repoFullName: poll.repoFullName, + authorCatId: (task.ownerCatId ?? 'unknown') as string, + threadId: task.threadId, + featureId, + phaseLabel: phaseMatch?.[1] ?? 'unknown', + }); + } + } catch { + log.warn( + `[CiCdRouter] distillation checkpoint (feat-phase-close) failed for ${poll.repoFullName}#${poll.prNumber}`, + ); + } + } + + // F168 Phase A P1-3: canonical community event emission. + // This is the first reliable detection point for PR lifecycle. + // sourceEventId = `lifecycle:${sk}:${prState}` — dedup-safe if ReviewFeedbackTaskSpec also fires. + if (this.opts.eventLog) { + try { + const eventKind = poll.prState === 'merged' ? 'pr.merged' : 'pr.closed'; + const communityEvent = { + sourceEventId: `lifecycle:${sk}:${poll.prState}`, + subjectKey: sk, + kind: eventKind as 'pr.merged' | 'pr.closed', + classification: 'state-changing' as const, + payload: { + prState: poll.prState, + repoFullName: poll.repoFullName, + prNumber: poll.prNumber, + }, + at: Date.now(), + }; + const { appended } = await this.opts.eventLog.append(communityEvent); + if (appended && this.opts.projector) { + await this.opts.projector.apply(communityEvent); + } + } catch (err) { + log.warn( + { err, repoFullName: poll.repoFullName, prNumber: poll.prNumber, subjectKey: sk }, + '[CiCdRouter] event log append failed (best-effort, spec §Task6)', + ); + } + } + } + private async deliver( poll: CiPollResult, task: { @@ -208,6 +319,7 @@ export class CiCdRouter { ): Promise { const { taskStore, log } = this.opts; const content = buildCiMessageContent(poll, task.automationState?.trackingInstructions); + const deliveryTarget = getConnectorDeliveryTarget(task); const source: ConnectorSource = { connector: 'github-ci', @@ -217,9 +329,7 @@ export class CiCdRouter { }; const result = await deliverConnectorMessage(this.opts.deliveryDeps, { - threadId: task.threadId, - userId: task.userId ?? '', - catId: task.ownerCatId ?? '', + ...deliveryTarget, content, source, }); @@ -241,43 +351,10 @@ export class CiCdRouter { return { kind: 'notified', threadId: task.threadId, - catId: task.ownerCatId ?? '', + catId: deliveryTarget.catId, messageId: result.messageId, bucket: poll.aggregateBucket, content, }; } } - -export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: string): string { - const bucketEmoji = poll.aggregateBucket === 'pass' ? '✅' : '❌'; - const bucketLabel = poll.aggregateBucket === 'pass' ? 'CI 通过' : 'CI 失败'; - - const lines: string[] = [ - `${bucketEmoji} **${bucketLabel}**`, - '', - `PR #${poll.prNumber} (${poll.repoFullName})`, - `Commit: \`${poll.headSha.slice(0, 7)}\``, - ]; - - const failedChecks = poll.checks.filter((c) => c.bucket === 'fail'); - if (failedChecks.length > 0) { - lines.push('', `--- 失败的检查 (${failedChecks.length}) ---`); - for (const check of failedChecks) { - const linkPart = check.link ? ` [查看](${check.link})` : ''; - const descPart = check.description ? ` — ${check.description.slice(0, 120)}` : ''; - lines.push(`❌ **${check.name}**${descPart}${linkPart}`); - } - } - - if (poll.aggregateBucket === 'fail') { - lines.push('', '请检查 CI 失败原因并修复。'); - } - - // F202 Phase 2C (AC-C2): append user-provided tracking instructions - if (trackingInstructions) { - lines.push('', '📌 **Tracking Instructions**', trackingInstructions); - } - - return lines.join('\n'); -} diff --git a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts index 8c4bbb8f01..b3d31c5582 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts @@ -83,6 +83,16 @@ export interface ReviewFeedbackTaskSpecOptions { readonly projector?: { apply(event: CommunityEvent): Promise }; // F208 Phase E AC-E2: distillation checkpoint (best-effort, optional) readonly distillationCheckpoint?: DistillationCheckpoint; + /** F167 Phase Q: retire matching hold_ball timers once structured review feedback is delivered. */ + readonly holdLifecycle?: { + retireSatisfiedWait(event: { + threadId: string; + subjectKey: string; + expectedSignalKey: 'review_posted'; + sourceKind: 'review_feedback'; + sourceMessageId?: string; + }): void | Promise; + }; } function resolveCursor(memoryCursor: number | undefined, persistedCursor: number | undefined): number { @@ -300,6 +310,7 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions const prMetadata = opts.fetchPrMetadata ? await opts.fetchPrMetadata(repoFullName, prNumber) : null; if (prMetadata?.prState === 'merged' || prMetadata?.prState === 'closed') { + await opts.taskStore.patchAutomationState(trackingTask.id, { review: { prState: prMetadata.prState } }); await opts.taskStore.update(trackingTask.id, { status: 'done' }); opts.log.info(`[review-feedback] PR ${prKey} ${prMetadata.prState} — task marked done`); @@ -616,6 +627,20 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions if (repairCommitted === false) return; await signal.commitCursor(); + if (opts.holdLifecycle) { + try { + await opts.holdLifecycle.retireSatisfiedWait({ + threadId: routeResult.threadId, + subjectKey, + expectedSignalKey: 'review_posted', + sourceKind: 'review_feedback', + sourceMessageId: routeResult.messageId, + }); + } catch (err) { + opts.log.warn({ err, subjectKey }, '[review-feedback] hold lifecycle retirement failed (best-effort)'); + } + } + if (opts.invokeTrigger) { try { const hasChangesRequested = signal.newDecisions.some((d) => d.state === 'CHANGES_REQUESTED'); diff --git a/packages/api/src/infrastructure/email/ci-message-content.ts b/packages/api/src/infrastructure/email/ci-message-content.ts new file mode 100644 index 0000000000..bb322ff4bc --- /dev/null +++ b/packages/api/src/infrastructure/email/ci-message-content.ts @@ -0,0 +1,63 @@ +/** + * Message content builders for CI/CD tracking notifications. + * Extracted from CiCdRouter.ts so lifecycle delivery can stay small and reusable. + */ +import type { CiPollResult } from './CiCdRouter.js'; + +export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: string): string { + const bucketEmoji = poll.aggregateBucket === 'pass' ? '✅' : '❌'; + const bucketLabel = poll.aggregateBucket === 'pass' ? 'CI 通过' : 'CI 失败'; + + const lines: string[] = [ + `${bucketEmoji} **${bucketLabel}**`, + '', + `PR #${poll.prNumber} (${poll.repoFullName})`, + `Commit: \`${poll.headSha.slice(0, 7)}\``, + ]; + + const failedChecks = poll.checks.filter((c) => c.bucket === 'fail'); + if (failedChecks.length > 0) { + lines.push('', `--- 失败的检查 (${failedChecks.length}) ---`); + for (const check of failedChecks) { + const linkPart = check.link ? ` [查看](${check.link})` : ''; + const descPart = check.description ? ` — ${check.description.slice(0, 120)}` : ''; + lines.push(`❌ **${check.name}**${descPart}${linkPart}`); + } + } + + if (poll.aggregateBucket === 'fail') { + lines.push('', '请检查 CI 失败原因并修复。'); + } + + // F202 Phase 2C (AC-C2): append user-provided tracking instructions + if (trackingInstructions) { + lines.push('', '📌 **Tracking Instructions**', trackingInstructions); + } + + return lines.join('\n'); +} + +/** Terminal lifecycle (merged/closed) notification. */ +export function buildLifecycleMessageContent( + poll: Pick, + trackingInstructions?: string, +): string { + const merged = poll.prState === 'merged'; + const headline = merged ? '🎉 **PR 已 merge**' : '🚪 **PR 已关闭(未合并)**'; + + const lines: string[] = [headline, '', `PR #${poll.prNumber} (${poll.repoFullName})`]; + + lines.push( + '', + merged + ? '请执行 post-merge 收尾(验证 main、更新任务状态、清理分支/worktree)。' + : '该 PR 未合并即关闭,请确认是否需要跟进(重开、改道或收尾归档)。', + ); + + // F202 Phase 2C (AC-C2): append user-provided tracking instructions + if (trackingInstructions) { + lines.push('', '📌 **Tracking Instructions**', trackingInstructions); + } + + return lines.join('\n'); +} diff --git a/packages/api/test/cicd-router.test.js b/packages/api/test/cicd-router.test.js index 39f444ed09..cc487ad08f 100644 --- a/packages/api/test/cicd-router.test.js +++ b/packages/api/test/cicd-router.test.js @@ -2,7 +2,11 @@ import assert from 'node:assert'; import { beforeEach, describe, it } from 'node:test'; -import { buildCiMessageContent, CiCdRouter } from '../dist/infrastructure/email/CiCdRouter.js'; +import { + buildCiMessageContent, + buildLifecycleMessageContent, + CiCdRouter, +} from '../dist/infrastructure/email/CiCdRouter.js'; import { createPrTrackingTaskStore } from './helpers/pr-tracking-test-helper.js'; // ─── Lightweight mocks ───────────────────────────────────────────── @@ -133,7 +137,32 @@ describe('CiCdRouter', () => { assert.strictEqual(messageMock.messages[0].source.connector, 'github-ci'); }); - it('delivers CI success message to tracked thread (AC-A3)', async () => { + it('suppresses CI success delivery for review intent while preserving CI state', async () => { + const router = createRouter(); + const task = prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + prTracking.taskStore.patchAutomationState(task.id, { intent: 'review' }); + + const result = await router.route(makePollResult({ aggregateBucket: 'pass', checks: [] })); + + assert.strictEqual(result.kind, 'skipped'); + assert.ok(result.reason.includes('review intent')); + assert.strictEqual(messageMock.messages.length, 0); + + const updated = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); + assert.ok(updated, 'entry should still exist after silent CI pass'); + assert.strictEqual(updated.automationState?.ci?.headSha, 'abc1234567890'); + assert.strictEqual(updated.automationState?.ci?.lastFingerprint, 'abc1234567890:pass'); + assert.strictEqual(updated.automationState?.ci?.lastBucket, 'pass'); + assert.strictEqual(updated.automationState?.ci?.lastNotifiedAt, undefined); + }); + + it('suppresses CI success delivery when intent is absent (defaults to review)', async () => { const router = createRouter(); prTracking.register({ repoFullName: 'zts212653/cat-cafe', @@ -145,6 +174,24 @@ describe('CiCdRouter', () => { const result = await router.route(makePollResult({ aggregateBucket: 'pass', checks: [] })); + assert.strictEqual(result.kind, 'skipped'); + assert.ok(result.reason.includes('review intent')); + assert.strictEqual(messageMock.messages.length, 0); + }); + + it('delivers CI success message to tracked thread for merge intent (AC-A3)', async () => { + const router = createRouter(); + const task = prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + prTracking.taskStore.patchAutomationState(task.id, { intent: 'merge' }); + + const result = await router.route(makePollResult({ aggregateBucket: 'pass', checks: [] })); + assert.strictEqual(result.kind, 'notified'); if (result.kind === 'notified') { assert.strictEqual(result.bucket, 'pass'); @@ -221,13 +268,14 @@ describe('CiCdRouter', () => { it('fail then success on same SHA notifies both (AC-A5)', async () => { const router = createRouter(); - prTracking.register({ + const task = prTracking.register({ repoFullName: 'zts212653/cat-cafe', prNumber: 42, catId: 'opus', threadId: 'thread-abc', userId: 'user-1', }); + prTracking.taskStore.patchAutomationState(task.id, { intent: 'merge' }); const failPoll = makePollResult({ headSha: 'sha-fixed', aggregateBucket: 'fail' }); const passPoll = makePollResult({ headSha: 'sha-fixed', aggregateBucket: 'pass', checks: [] }); @@ -270,31 +318,44 @@ describe('CiCdRouter', () => { }); }); - // ── T3: Merged/closed auto remove (AC-A8) ────────────────────── + // ── T3: Merged/closed lifecycle close (AC-A8 + terminal notification) ── - describe('T3: merged/closed auto remove', () => { - it('merged PR is removed from tracking store (AC-A8)', async () => { + describe('T3: merged/closed lifecycle close', () => { + it('merged PR marks task done AND delivers terminal lifecycle notification', async () => { const router = createRouter(); prTracking.register({ - repoFullName: 'zts212653/cat-cafe', + repoFullName: 'zts212653/clowder-ai', prNumber: 42, catId: 'opus', threadId: 'thread-abc', userId: 'user-1', }); - const result = await router.route(makePollResult({ prState: 'merged' })); + const result = await router.route(makePollResult({ repoFullName: 'zts212653/clowder-ai', prState: 'merged' })); - assert.strictEqual(result.kind, 'skipped'); - assert.ok(result.reason.includes('merged')); + assert.strictEqual(result.kind, 'lifecycle'); + if (result.kind === 'lifecycle') { + assert.strictEqual(result.prState, 'merged'); + assert.strictEqual(result.threadId, 'thread-abc'); + assert.strictEqual(result.catId, 'opus'); + assert.ok(result.messageId, 'delivered message id must be returned'); + } // #320: merged/closed sets status=done (not delete) - const entry = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); + const entry = prTracking.taskStore.getBySubject('pr:zts212653/clowder-ai#42'); assert.ok(entry, 'task should still exist'); assert.strictEqual(entry.status, 'done'); + + assert.strictEqual(messageMock.messages.length, 1); + const msg = messageMock.messages[0]; + assert.strictEqual(msg.threadId, 'thread-abc'); + assert.ok(msg.mentions.includes('opus')); + assert.ok(msg.content.includes('merge'), 'content should state the PR merged'); + assert.ok(msg.content.includes('#42')); + assert.strictEqual(msg.source.url, 'https://github.com/zts212653/clowder-ai/pull/42'); }); - it('closed PR is marked done in task store', async () => { + it('closed PR marks task done AND delivers closed (unmerged) notification', async () => { const router = createRouter(); prTracking.register({ repoFullName: 'zts212653/cat-cafe', @@ -306,12 +367,118 @@ describe('CiCdRouter', () => { const result = await router.route(makePollResult({ prState: 'closed' })); - assert.strictEqual(result.kind, 'skipped'); - assert.ok(result.reason.includes('closed')); + assert.strictEqual(result.kind, 'lifecycle'); + if (result.kind === 'lifecycle') { + assert.strictEqual(result.prState, 'closed'); + } const entry = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); assert.ok(entry, 'task should still exist'); assert.strictEqual(entry.status, 'done'); + + assert.strictEqual(messageMock.messages.length, 1); + assert.ok( + messageMock.messages[0].content.includes('未合并'), + 'closed wording must distinguish unmerged closure from merge', + ); + }); + + it('skips terminal lifecycle delivery when the same terminal state was already processed', async () => { + const router = createRouter(); + const task = prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + prTracking.taskStore.update(task.id, { status: 'done' }); + prTracking.taskStore.patchAutomationState(task.id, { ci: { prState: 'merged' } }); + + const result = await router.route(makePollResult({ prState: 'merged' })); + + assert.strictEqual(result.kind, 'skipped'); + if (result.kind === 'skipped') { + assert.ok(result.reason.includes('already processed')); + } + const entry = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); + assert.strictEqual(entry?.status, 'done'); + assert.strictEqual(messageMock.messages.length, 0); + }); + + it('still delivers terminal lifecycle when review feedback marked the task done first', async () => { + const router = createRouter(); + const task = prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + prTracking.taskStore.update(task.id, { status: 'done' }); + + const result = await router.route(makePollResult({ prState: 'merged' })); + + assert.strictEqual(result.kind, 'lifecycle'); + if (result.kind === 'lifecycle') { + assert.strictEqual(result.prState, 'merged'); + assert.strictEqual(result.threadId, 'thread-abc'); + assert.strictEqual(result.catId, 'opus'); + } + const entry = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); + assert.strictEqual(entry?.status, 'done'); + assert.strictEqual(entry?.automationState?.ci?.prState, 'merged'); + assert.strictEqual(messageMock.messages.length, 1); + }); + + it('appends trackingInstructions to the terminal notification (post-merge checklist)', async () => { + const router = createRouter(); + const task = prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + prTracking.taskStore.patchAutomationState(task.id, { + trackingInstructions: 'merge 后:验证 main 生效 + 标任务 done', + }); + + await router.route(makePollResult({ prState: 'merged' })); + + assert.strictEqual(messageMock.messages.length, 1); + const content = messageMock.messages[0].content; + assert.ok(content.includes('Tracking Instructions')); + assert.ok(content.includes('merge 后:验证 main 生效 + 标任务 done')); + }); + + it('delivery failure degrades to skipped but still marks task done (no poller crash)', async () => { + const failingStore = /** @type {any} */ ({ + append() { + throw new Error('message store down'); + }, + }); + const router = new CiCdRouter({ + taskStore: prTracking.taskStore, + deliveryDeps: { messageStore: failingStore }, + log: noopLog(), + }); + prTracking.register({ + repoFullName: 'zts212653/cat-cafe', + prNumber: 42, + catId: 'opus', + threadId: 'thread-abc', + userId: 'user-1', + }); + + const result = await router.route(makePollResult({ prState: 'merged' })); + + assert.strictEqual(result.kind, 'skipped'); + if (result.kind === 'skipped') { + assert.ok(result.reason.includes('merged')); + } + const entry = prTracking.taskStore.getBySubject('pr:zts212653/cat-cafe#42'); + assert.strictEqual(entry?.status, 'done', 'done-marking must survive delivery failure'); }); }); @@ -463,7 +630,7 @@ describe('CiCdRouter', () => { userId: 'user-1', }); const result = await router.route(makePollResult({ prState: 'merged' })); - assert.strictEqual(result.kind, 'skipped'); + assert.strictEqual(result.kind, 'lifecycle'); }); it('continues routing even if eventLog.append throws (best-effort)', async () => { @@ -487,7 +654,7 @@ describe('CiCdRouter', () => { // Must not throw const result = await router.route(makePollResult({ prState: 'merged' })); - assert.strictEqual(result.kind, 'skipped'); + assert.strictEqual(result.kind, 'lifecycle'); }); }); @@ -758,8 +925,10 @@ describe('CiCdRouter', () => { const result = await router.route( makePollResult({ repoFullName: 'zts212653/cat-cafe', prNumber: 77, prState: 'merged' }), ); - assert.strictEqual(result.kind, 'skipped'); - assert.ok(result.reason.includes('merged')); + assert.strictEqual(result.kind, 'lifecycle'); + if (result.kind === 'lifecycle') { + assert.strictEqual(result.prState, 'merged'); + } }); }); }); @@ -803,3 +972,33 @@ describe('buildCiMessageContent', () => { assert.ok(!content.includes('失败的检查')); }); }); + +describe('buildLifecycleMessageContent', () => { + it('formats merged lifecycle message with tracking instructions', () => { + const content = buildLifecycleMessageContent( + { + repoFullName: 'org/repo', + prNumber: 10, + prState: 'merged', + }, + 'verify main and close task', + ); + + assert.ok(content.includes('PR 已 merge')); + assert.ok(content.includes('PR #10')); + assert.ok(content.includes('Tracking Instructions')); + assert.ok(content.includes('verify main and close task')); + }); + + it('formats closed lifecycle message as unmerged', () => { + const content = buildLifecycleMessageContent({ + repoFullName: 'org/repo', + prNumber: 11, + prState: 'closed', + }); + + assert.ok(content.includes('PR 已关闭')); + assert.ok(content.includes('未合并')); + assert.ok(content.includes('PR #11')); + }); +}); diff --git a/packages/api/test/scheduler/cicd-check-spec.test.js b/packages/api/test/scheduler/cicd-check-spec.test.js index 235b768226..dce2188669 100644 --- a/packages/api/test/scheduler/cicd-check-spec.test.js +++ b/packages/api/test/scheduler/cicd-check-spec.test.js @@ -65,7 +65,7 @@ describe('CiCdCheckTaskSpec', () => { }); // ── F140 Phase C 部分回退:CI pass 由 tracking 的 wake intent 分流(显式声明,不猜 approval)── - // intent=review(默认)→ CI pass 静默(猫等 review,pass 是噪音,只留 thread 消息)。 + // intent=review(默认)→ CI pass 静默(猫等 review,pass 是噪音,只记录 CI state)。 // intent=merge → CI pass 唤醒(猫等 CI 绿去 merge,pass 是动作信号 → merge-gate)。 // CI fail 两种 intent 都 urgent 唤醒。intent 是显式任务意图,不是 repo 类型。 @@ -167,6 +167,112 @@ describe('CiCdCheckTaskSpec', () => { assert.equal(policy.reason, 'github_ci_failure'); }); + // ── PR terminal state (merged/closed) -> wake owner for follow-up ── + // Terminal events fire exactly once in production because CiCdRouter marks the + // task done and the scheduler gate filters done tasks. They still need an owner + // wake and hold-retirement signal: PR closure satisfies any matching CI wait. + + function lifecycleSpec(createCiCdCheckTaskSpec, triggered, retired, prState) { + const tasks = [mockTask({ repoFullName: 'a/b', prNumber: 1, userId: 'u1' })]; + return createCiCdCheckTaskSpec({ + taskStore: mockTaskStore(tasks), + cicdRouter: { + route: async () => ({ + kind: 'lifecycle', + prState, + threadId: 't1', + catId: 'opus', + messageId: 'm1', + content: `PR ${prState}`, + }), + }, + fetchPrStatus: async () => ({ + checks: [], + headSha: 'sha1', + prNumber: 1, + repoFullName: 'a/b', + prState, + aggregateBucket: 'pass', + }), + invokeTrigger: { + trigger: (...args) => { + triggered.push(args); + return Promise.resolve(); + }, + }, + holdLifecycle: { + retireSatisfiedWait: (event) => { + retired.push(event); + }, + }, + log: { info: () => {}, error: () => {}, warn: () => {} }, + }); + } + + it('execute WAKES owner and retires matching hold when PR merges', async () => { + const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); + const triggered = []; + const retired = []; + const spec = lifecycleSpec(createCiCdCheckTaskSpec, triggered, retired, 'merged'); + const gateResult = await spec.admission.gate({ taskId: 'cicd-check', lastRunAt: null, tickCount: 1 }); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:a/b#1', {}); + + assert.equal(retired.length, 1, 'merged -> matching CI hold must retire before owner wake'); + assert.deepEqual(retired[0], { + threadId: 't1', + subjectKey: 'pr:a/b#1', + expectedSignalKey: 'ci_complete', + sourceKind: 'ci_check', + sourceMessageId: 'm1', + }); + assert.equal(triggered.length, 1, 'merged -> owner must be woken for post-merge follow-up'); + assert.equal(triggered[0][0], 't1'); + assert.equal(triggered[0][1], 'opus'); + const policy = triggered[0][6]; + assert.equal(policy.priority, 'normal'); + assert.equal(policy.reason, 'github_pr_merged'); + assert.equal(policy.sourceCategory, 'ci'); + }); + + it('execute WAKES owner and retires matching hold when PR closes without merge', async () => { + const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); + const triggered = []; + const retired = []; + const spec = lifecycleSpec(createCiCdCheckTaskSpec, triggered, retired, 'closed'); + const gateResult = await spec.admission.gate({ taskId: 'cicd-check', lastRunAt: null, tickCount: 1 }); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:a/b#1', {}); + + assert.equal(retired.length, 1, 'closed -> matching CI hold must retire'); + assert.equal(triggered.length, 1, 'closed -> owner must know the PR is gone'); + assert.equal(triggered[0][6].reason, 'github_pr_closed'); + }); + + it('gate admits review-terminal done tasks until CI lifecycle records prState', async () => { + const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); + const tasks = [ + mockTask( + { repoFullName: 'a/b', prNumber: 1 }, + { status: 'done', automationState: { review: { prState: 'merged' } } }, + ), + mockTask( + { repoFullName: 'c/d', prNumber: 2 }, + { status: 'done', automationState: { review: { prState: 'closed' }, ci: { prState: 'closed' } } }, + ), + mockTask({ repoFullName: 'e/f', prNumber: 3 }, { status: 'done' }), + ]; + const spec = createCiCdCheckTaskSpec({ + taskStore: mockTaskStore(tasks), + cicdRouter: { route: async () => ({ kind: 'noop' }) }, + log: { info: () => {}, error: () => {}, warn: () => {} }, + }); + + const result = await spec.admission.gate({ taskId: 'cicd-check', lastRunAt: null, tickCount: 1 }); + + assert.equal(result.run, true); + assert.equal(result.workItems.length, 1); + assert.equal(result.workItems[0].subjectKey, 'pr:a/b#1'); + }); + it('gate filters out ci.enabled=false', async () => { const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); const tasks = [ diff --git a/packages/api/test/scheduler/review-feedback-spec.test.js b/packages/api/test/scheduler/review-feedback-spec.test.js index 649eed36c7..346887698c 100644 --- a/packages/api/test/scheduler/review-feedback-spec.test.js +++ b/packages/api/test/scheduler/review-feedback-spec.test.js @@ -897,9 +897,17 @@ describe('ReviewFeedbackTaskSpec', () => { assert.equal(result.run, false); assert.equal(fetchCalled, false, 'merged PRs must not fetch review feedback'); + assert.equal(store._patchCalls.length, 1); + assert.equal(store._patchCalls[0].taskId, mockTaskItem.id); + assert.deepEqual(store._patchCalls[0].patch.review, { prState: 'merged' }); assert.equal(store._updateCalls.length, 1); assert.equal(store._updateCalls[0].taskId, mockTaskItem.id); assert.equal(store._updateCalls[0].input.status, 'done'); + assert.equal( + store._updateCalls[0].input.automationState, + undefined, + 'marking done must not replace existing automationState', + ); }); it('gate continues with fresh feedback when PR metadata lookup is unavailable', async () => { diff --git a/packages/shared/src/types/task.ts b/packages/shared/src/types/task.ts index 4a63c7accf..ae04b9f944 100644 --- a/packages/shared/src/types/task.ts +++ b/packages/shared/src/types/task.ts @@ -58,13 +58,15 @@ export interface ReviewAutomationState { readonly lastCommentCursor?: number; readonly lastDecisionCursor?: number; readonly lastNotifiedAt?: number; + /** Terminal PR state observed by ReviewFeedbackTaskSpec before CI lifecycle delivery. */ + readonly prState?: 'merged' | 'closed'; } /** * F140: what the cat is currently waiting on for this tracked PR — the wake intent, NOT the repo * type (a private PR can be 'merge'; an open-source PR can be 'review'). Decides whether a CI-pass * is noise (review-wait) or an action signal (merge-wait). Cats re-register to flip it. - * - review (default): waiting on review feedback → CI-pass stays silent (thread message only). + * - review (default): waiting on review feedback → CI-pass is recorded state-only, with no connector message. * - merge: waiting on CI-green to merge (own approved PR / outbound PR / owner-merge of another's * PR) → CI-pass wakes (→ merge-gate). * CI fail / review feedback / conflict always wake under both intents. From e833314ef5322598567526b1719af34ba66ba8cd Mon Sep 17 00:00:00 2001 From: bouillipx Date: Thu, 9 Jul 2026 11:06:41 +0800 Subject: [PATCH 05/15] =?UTF-8?q?verdict(eval:a2a):=202026-07-09-eval-a2a-?= =?UTF-8?q?restart-clean-keep-observe=20=E2=80=94=20keep=5Fobserve=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-09 window is clean on current counters: C2 forced-pass is 0/61 and void-hold is 0/61, with no C1 zombie-hold finding and no grounding mismatches. Trend interpretation is downgraded because the API process restarted during the trace window: trace coverage is 21.62h, but counter_window is 18.80h and the C2 denominator is far below the prior high-traffic windows. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 11 +++ .../provenance.json | 19 +++++ .../snapshot.json | 84 +++++++++++++++++++ ...-09-eval-a2a-restart-clean-keep-observe.md | 35 ++++++++ 4 files changed, 149 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-09-eval-a2a-restart-clean-keep-observe.md diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/attribution.json new file mode 100644 index 0000000000..c8218e2ad7 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-09-eval-a2a-restart-clean-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-09", + "generatedAt": "2026-07-09T03:02:43.611Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 5 components", + "evidence": "Checked components: L1, C1, C2, route-serial, grounding-phase-o. Friction metrics examined: c1.hold_zombie_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.feedback_written, inline_action.hint_emitted, grounding.budget_exhausted_total. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/provenance.json new file mode 100644 index 0000000000..21c89a27b8 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-09-eval-a2a-restart-clean-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-09-F167-eval.yaml", + "sha256": "d16607f63746714b582ff7aea4949ab95f7171c1d341bc3c148600dbb113ab17" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-09-F167-attribution.yaml", + "sha256": "1d3bcd3be304eec7d0771fa918ef531bb9282951da68a4362188de372177d98e" + } + ], + "generatedAt": "2026-07-09T03:02:43.611Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot.json new file mode 100644 index 0000000000..319db1581a --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot.json @@ -0,0 +1,84 @@ +{ + "verdictId": "2026-07-09-eval-a2a-restart-clean-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-09", + "featureId": "F167", + "generatedAt": "2026-07-09T03:02:43.609Z", + "window": { + "startMs": 1783488221877, + "endMs": 1783566052518, + "durationHours": 21.6196225 + }, + "counterWindow": { + "startMs": 1783498472883, + "endMs": 1783566163554, + "durationHours": 18.802964052719723 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0, + "c1.hold_replacement_count": 0 + }, + "frictionCounts": { + "c1.hold_zombie_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 1, + "c2.verdict_hint_emitted": 0, + "c2.checked": 61, + "c2.void_hold_checked": 61 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 0 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 61, + "inline_action.detected": 1, + "line_start.detected": 8 + }, + "frictionCounts": { + "inline_action.feedback_written": 1, + "inline_action.hint_emitted": 1 + } + }, + { + "id": "grounding-phase-o", + "name": "claim grounding (Phase O shadow)", + "confidence": "medium", + "activationCounts": { + "grounding.check_total": 0, + "grounding.verdict_total": 0, + "grounding.resolver_total": 0, + "grounding.cache_hit_total": 0 + }, + "frictionCounts": { + "grounding.budget_exhausted_total": 0 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-09-eval-a2a-restart-clean-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-09-eval-a2a-restart-clean-keep-observe.md new file mode 100644 index 0000000000..9a1e3c0225 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-09-eval-a2a-restart-clean-keep-observe.md @@ -0,0 +1,35 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-09-eval-a2a-restart-clean-keep-observe +source_snapshot: "snapshot:bundle/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-09-eval-a2a-restart-clean-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: The 2026-07-09 window is clean on current counters: C2 forced-pass is 0/61 and void-hold is 0/61, with no C1 zombie-hold finding and no grounding mismatches. Trend interpretation is downgraded because the API process restarted during the trace window: trace coverage is 21.62h, but counter_window is 18.80h and the C2 denominator is far below the prior high-traffic windows. +- Harness: F167/C2 (A2A Chain Quality runtime harness: exit-check / void-hold / grounding shadow telemetry) +- Owner ask: No code action from this 7/9 packet. Continue ordinary eval:a2a monitoring; if the next high-denominator window returns void_hold above roughly 2.5% or keeps per-fire sample coverage below 80%, promote a separate F167 tracer coverage build ask before the 5% floor is crossed. +- Re-eval: Keep ordinary monitoring while verdict_without_pass stays at zero or isolated low counts, void_hold remains below the 5% floor, and grounding mismatch_sample_count remains zero. Reopen as build if a full counter window repeats the 7/8 drift shape or if sample coverage remains below 80% on recurring void_hold emissions. at 2026-07-10T03:01:28Z + +Evidence: +- snapshot:bundle/2026-07-09-eval-a2a-restart-clean-keep-observe/snapshot +- attribution:bundle/2026-07-09-eval-a2a-restart-clean-keep-observe/eval-F167-2026-07-09:no-finding +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:grounding.check_total +- metric:grounding.mismatch_sample_count +- metadata:traceStoreStats/spanCount=321/counterWindowHours=18.80 + +Counterarguments: +- The 7/9 C2 denominator is only 61 after a process restart, while 7/8 had 551 checks; 0/61 should not be interpreted as a statistically strong improvement over 14/551. +- The 7/3-7/8 sequence rose monotonically from 1.21% to 2.54%, so one low-denominator clean window does not erase the drift hypothesis. +- No void_hold sample rows exist in 7/9 because no void_hold events fired; the persistent sample coverage question is therefore not resolved by this packet. +- Grounding shadow telemetry reported zero checks and zero samples; that is healthy for mismatch count, but does not prove the grounding path was exercised by stateful tool calls in this window. From 5d178c1985f9a15748df847815c5a0f1d6ea8f67 Mon Sep 17 00:00:00 2001 From: bouillipx Date: Thu, 9 Jul 2026 11:10:07 +0800 Subject: [PATCH 06/15] =?UTF-8?q?verdict(eval:friction):=202026-07-09-eval?= =?UTF-8?q?-friction-timeout-singleton-with-recurring-reference-only=20?= =?UTF-8?q?=E2=80=94=20keep=5Fobserve=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The every-3d friction window from 2026-07-06 03:00 UTC to 2026-07-09 03:00 UTC surfaced one new high-severity actionable singleton (`a2a_timeout: codex`) from the `Coactive 架构` thread, while the other four top clusters were recurring `eval:a2a` reference-only counters (`c2.void_hold_hint_emitted` plus three `inline_action.*` signals). The rollup remained degraded and the timeout had no second-channel echo, so the new signal looks like an isolated interruption rather than a stable cross-channel friction pattern. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 36 ++ .../provenance.json | 15 + .../raw/rollup-report.json | 313 ++++++++++++++++++ .../snapshot.json | 30 ++ ...singleton-with-recurring-reference-only.md | 31 ++ 5 files changed, 425 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/raw/rollup-report.json create mode 100644 docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only.md diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/attribution.json b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/attribution.json new file mode 100644 index 0000000000..7d00c04ba6 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/attribution.json @@ -0,0 +1,36 @@ +{ + "verdictId": "2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only", + "featureId": "F245", + "evalSnapshotId": "eval-F245-2026-07-09", + "generatedAt": "2026-07-09T03:09:18.602Z", + "findings": [ + { + "id": "FR-2026-07-09-d981127d413a", + "relatedFeature": "F245", + "frictionSignal": { + "type": "friction.cluster_d981127d413a", + "severity": "high", + "confidence": 0.7, + "detectedAt": "2026-07-09T03:09:18.602Z" + }, + "attribution": { + "primaryLayer": "needs_investigation", + "evidence": [ + { + "type": "counter", + "anchor": "friction-rollup/cluster_d981127d413a", + "excerpt": "cluster 'a2a_timeout: codex' count=1 severity=high sensorForms=[reason]" + } + ] + }, + "proposedAction": [ + { + "action": "triage-top-friction-cluster", + "target": "F245/friction-rollup", + "rationale": "Highest-ranked friction cluster in the window — eval cat assigns the 7-class root cause in the verdict before handoff." + } + ], + "status": "open" + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/provenance.json b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/provenance.json new file mode 100644 index 0000000000..c751f6d94d --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/provenance.json @@ -0,0 +1,15 @@ +{ + "verdictId": "2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only", + "rawInputs": [ + { + "path": "docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/raw/rollup-report.json", + "sha256": "2152e3490b03474074c42fc4d3433908f52dc8aeddc04de378b228bb21c0a25e" + } + ], + "generatedAt": "2026-07-09T03:09:18.602Z", + "generator": { + "name": "eval-friction-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f245-friction-rollup-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/raw/rollup-report.json b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/raw/rollup-report.json new file mode 100644 index 0000000000..fe978ec290 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/raw/rollup-report.json @@ -0,0 +1,313 @@ +{ + "verdictId": "2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only", + "selector": { + "kind": "friction-rollup-snapshot", + "windowStartMs": 1783306800000, + "windowEndMs": 1783566000000, + "topN": 10, + "tokenCap": 4000 + }, + "window": { + "sinceMs": 1783306800000, + "untilMs": 1783566000000 + }, + "signalCount": 9, + "clusterCount": 5, + "degraded": false, + "droppedChannels": [], + "report": { + "window": { + "sinceMs": 1783306800000, + "untilMs": 1783566000000 + }, + "generatedAt": "2026-07-09T03:09:18.602Z", + "topClusters": [ + { + "clusterId": "d981127d413a", + "representative": "a2a_timeout: codex", + "channels": [ + "user-feedback" + ], + "count": 1, + "members": [ + { + "signalId": "user-feedback:fi_mrcu4bxlv840fgsx", + "rawRef": "fi_mrcu4bxlv840fgsx", + "channel": "user-feedback" + } + ], + "method": "rule", + "sensorForms": [ + "reason" + ], + "severity": "high" + }, + { + "clusterId": "23fba4045727", + "representative": "c2.void_hold_hint_emitted=6", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#C2#c2.void_hold_hint_emitted", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#C2#c2.void_hold_hint_emitted", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#C2#c2.void_hold_hint_emitted", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#C2#c2.void_hold_hint_emitted", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low" + }, + { + "clusterId": "4901ab640cdd", + "representative": "inline_action.hint_emitted=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.hint_emitted", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.hint_emitted", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.hint_emitted", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.hint_emitted", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low" + }, + { + "clusterId": "9d32022cb1ab", + "representative": "inline_action.feedback_written=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.feedback_written", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.feedback_written", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.feedback_written", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.feedback_written", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low" + }, + { + "clusterId": "a60da62bea20", + "representative": "inline_action.routed_set_skip=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.routed_set_skip", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.routed_set_skip", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.routed_set_skip", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.routed_set_skip", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low" + } + ], + "actionableCandidates": [ + { + "clusterId": "d981127d413a", + "representative": "a2a_timeout: codex", + "channels": [ + "user-feedback" + ], + "count": 1, + "members": [ + { + "signalId": "user-feedback:fi_mrcu4bxlv840fgsx", + "rawRef": "fi_mrcu4bxlv840fgsx", + "channel": "user-feedback" + } + ], + "method": "rule", + "sensorForms": [ + "reason" + ], + "severity": "high", + "actionability": "actionable_candidate", + "followupDraft": { + "clusterId": "d981127d413a", + "title": "Investigate friction cluster: a2a_timeout: codex", + "summary": "a2a_timeout: codex", + "evidenceRefs": [ + "fi_mrcu4bxlv840fgsx" + ], + "reportingMode": "final-only" + }, + "referenceOnlyEvidenceRefs": [] + } + ], + "referenceOnly": [ + { + "clusterId": "23fba4045727", + "representative": "c2.void_hold_hint_emitted=6", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#C2#c2.void_hold_hint_emitted", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#C2#c2.void_hold_hint_emitted", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#C2#c2.void_hold_hint_emitted", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#C2#c2.void_hold_hint_emitted", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low", + "actionability": "reference_only", + "evidenceRefs": [ + "2026-07-06-eval-a2a-ordinary-monitoring#C2#c2.void_hold_hint_emitted", + "2026-07-07-eval-a2a-stable-void-hold-baseline#C2#c2.void_hold_hint_emitted" + ] + }, + { + "clusterId": "4901ab640cdd", + "representative": "inline_action.hint_emitted=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.hint_emitted", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.hint_emitted", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.hint_emitted", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.hint_emitted", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low", + "actionability": "reference_only", + "evidenceRefs": [ + "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.hint_emitted", + "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.hint_emitted" + ] + }, + { + "clusterId": "9d32022cb1ab", + "representative": "inline_action.feedback_written=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.feedback_written", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.feedback_written", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.feedback_written", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.feedback_written", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low", + "actionability": "reference_only", + "evidenceRefs": [ + "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.feedback_written", + "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.feedback_written" + ] + }, + { + "clusterId": "a60da62bea20", + "representative": "inline_action.routed_set_skip=1", + "channels": [ + "eval-domain" + ], + "count": 2, + "members": [ + { + "signalId": "eval-domain:2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.routed_set_skip", + "rawRef": "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.routed_set_skip", + "channel": "eval-domain" + }, + { + "signalId": "eval-domain:2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.routed_set_skip", + "rawRef": "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.routed_set_skip", + "channel": "eval-domain" + } + ], + "method": "rule", + "sensorForms": [ + "aggregate_proxy" + ], + "severity": "low", + "actionability": "reference_only", + "evidenceRefs": [ + "2026-07-06-eval-a2a-ordinary-monitoring#route-serial#inline_action.routed_set_skip", + "2026-07-07-eval-a2a-stable-void-hold-baseline#route-serial#inline_action.routed_set_skip" + ] + } + ], + "tailSummary": { + "clusterCount": 0, + "signalCount": 0, + "byChannel": {} + }, + "degraded": false, + "droppedChannels": [], + "tokenBudget": { + "cap": 4000, + "estimated": 1798 + } + } +} diff --git a/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot.json b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot.json new file mode 100644 index 0000000000..c0d97f371b --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot.json @@ -0,0 +1,30 @@ +{ + "verdictId": "2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only", + "evalSnapshotId": "eval-F245-2026-07-09", + "featureId": "F245", + "generatedAt": "2026-07-09T03:09:18.602Z", + "window": { + "startMs": 1783306800000, + "endMs": 1783566000000, + "durationHours": 72 + }, + "components": [ + { + "id": "friction-rollup", + "name": "friction rollup (Top-N + sensorForm)", + "confidence": "medium", + "activationCounts": {}, + "frictionCounts": { + "cluster_count": 5, + "top_cluster_count": 5, + "tail_cluster_count": 0, + "tail_signal_count": 0, + "cluster_d981127d413a": 1, + "cluster_23fba4045727": 2, + "cluster_4901ab640cdd": 2, + "cluster_9d32022cb1ab": 2, + "cluster_a60da62bea20": 2 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only.md b/docs/harness-feedback/verdicts/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only.md new file mode 100644 index 0000000000..cdbc34bd39 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only.md @@ -0,0 +1,31 @@ +--- +feature_ids: [F245] +topics: [harness-eval, eval-friction, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:friction +packet_id: 2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only +source_snapshot: "snapshot:bundle/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot" +--- + +# Live Verdict — 2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only + +- Verdict: `keep_observe` +- Phenomenon: The every-3d friction window from 2026-07-06 03:00 UTC to 2026-07-09 03:00 UTC surfaced one new high-severity actionable singleton (`a2a_timeout: codex`) from the `Coactive 架构` thread, while the other four top clusters were recurring `eval:a2a` reference-only counters (`c2.void_hold_hint_emitted` plus three `inline_action.*` signals). The rollup remained degraded and the timeout had no second-channel echo, so the new signal looks like an isolated interruption rather than a stable cross-channel friction pattern. +- Harness: F245/friction-rollup (friction rollup (Top-N + sensorForm)) +- Root cause: Most likely `environment_drift`: the only actionable cluster is a confirmed `a2a_timeout: codex` driven by Codex CLI/backend disconnects in the `Coactive 架构` thread, while the remaining top clusters are recurring `eval:a2a` reference-only counters rather than a fresh friction-harness defect. Confidence stays low because this is a singleton and the rollup remained degraded (rule-only clustering). (confidence low) +- Owner ask: Keep the every-3d friction rollup running and escalate only if `a2a_timeout: codex` recurs, picks up a second channel, or the recurring `eval:a2a` reference-only clusters turn into a new source-domain verdict. +- Re-eval: next eval at 2026-07-12T03:00:00.000Z + +Evidence: +- snapshot:bundle/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/snapshot +- attribution:bundle/2026-07-09-eval-friction-timeout-singleton-with-recurring-reference-only/FR-2026-07-09-d981127d413a +- metric:friction-rollup.cluster_count +- metric:friction-rollup.top_cluster_count +- metric:friction-rollup.cluster_d981127d413a +- metric:friction-rollup.cluster_23fba4045727 + +Counterarguments: +- A single high-severity timeout that lasted more than twelve minutes may still justify immediate owner investigation instead of another observation cycle. +- The recurring reference-only `eval:a2a` counters may already be persistent enough that friction should escalate them again rather than only cite them. +- Because the current rollup was degraded, the apparent singleton may reflect under-clustering rather than a genuinely isolated event. \ No newline at end of file From ebffcd8e5a6e1598dd2b34c3bc8e6c199db06935 Mon Sep 17 00:00:00 2001 From: lang <39692836+mindfn@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:55:27 -0700 Subject: [PATCH 07/15] =?UTF-8?q?feat(F237):=20Phase=202=20=E2=80=94=20Hoo?= =?UTF-8?q?k=20Pipeline=20migration=20with=20trace=20bridging=20(#1075)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(F237): Phase 2 — Hook Pipeline migration with trace bridging Migrates prompt injection from ad-hoc if/push in SystemPromptBuilder to a structured HookPipeline with 46 typed manifests, resolvers, and trace bridging to v0 InjectionTrace persistence. Key changes: - HookRegistry: scans assets/prompt-hooks/ for 46 hook.yaml manifests - HookPipeline: resolve → fire → trace execution engine - PipelinePromptBuilder: drop-in replacement for legacy builder - Scope filters: S-prefix for session-init, D-prefix for per-turn - Trace bridging: pipeline drains to v0 trace (sole persist path) - Concierge ordering: spliced between D17/D18, matching legacy Runtime overrides (HookOverrideStore) deferred to PR 3 per maintainer scope gate. All deliverable rows, ACs, and architecture sections updated to reflect baseline-only APIs in PR 2. 170 F237 tests pass. Zero behavior change (AC-P2-14). [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 * fix(F237): manifest endpoint → HookRegistry, hook content fallback Why: sync commit c9d100389 deleted assets/prompt-injection-manifest.yaml (replaced by 46 individual hook.yaml files in Phase 2), but the /api/prompt-injection/manifest endpoint in rules.ts still read the old file → 404. Additionally, resolveHookContent only handled H-prefix sourceType='hook' segments, so R1/R2/B1 (templates in hook dirs, not in prompt-templates/) returned "not template-backed" errors. Changes: - New prompt-injection-manifest.ts route: scans HookRegistry, maps HookManifest → ManifestSegment (derives category/consumer/sourceType from ID prefix), returns schemaVersion 2.0.0 - Rewrite prompt-injection-hooks.ts: resolveHookContent now uses HookRegistry to locate templates in hook dirs (fallback for segments not in TEMPLATE_FILES) - Remove old manifest endpoint from rules.ts (also removes unused YAML import, bringing file under 350-line limit) Tests: 120/120 F237-specific tests pass. Co-Authored-By: Claude Opus 4.6 * fix(f237): supplement manifest with 6 non-hook segments (N2/M1/M2/H1-H3) Why: Codex P1-2 correctly identified that the manifest endpoint only returned 46 HookRegistry segments, while the feature spec declares 52 segments total. The missing 6 are outside the hook pipeline: - N2: conversation history delta (observe-only, route-assembler) - M1: dispatch mission context (observe-only, invocation-layer) - M2: transcript path hints (observe-only, invocation-layer) - H1: Claude Code SessionStart hook (external, shell-hook) - H2: Claude Code PostCompact hook (external, shell-hook) - H3: Claude Code SessionStop hook (external, shell-hook) Added SUPPLEMENTAL_SEGMENTS array with full ManifestSegment metadata for all 6. Response now includes totalActive (46 hooks), totalObserveOnly (3 adapters), and totalExternal (3 shell hooks) for clear categorization. [布偶猫/Claude Opus 4.6🐾] Co-Authored-By: Claude Opus 4.6 * fix(f237): cascade manifest contract change to frontend + feature doc Why: R2 review correctly identified two contract-consistency gaps from the manifest supplementation fix: 1. Frontend ManifestResponse still referenced `totalLegacy` (removed from API), causing `undefined 遗留` in Console badge. Updated to use new `totalObserveOnly` + `totalExternal` fields. 2. Feature doc still claimed 49/52 complete trace observability via observe-only adapters, but adapters have no production call-sites. Added caveats at 6 locations (motivation, scope summary, trace architecture, P2-C description, AC-P2-4, AC-P2-13) clarifying: adapter API + unit tests delivered, production wiring deferred due to execution order constraints. Failure-mode audit: scanned all consumers of manifest response shape and all 49/52 trace claims — no additional uncascaded references found. [布偶猫/Claude Opus 4.6🐾] Co-Authored-By: Claude Opus 4.6 * fix(f237): preserve D2 direct-message hint for unknown senders Why: Legacy builder emitted D2 (direct-message source) with raw catId and 'unknown' model when the sender wasn't in catRegistry (stale/pending A2A handoff). The new context-assembler returned null instead, suppressing D2 entirely and losing the routing hint for the receiving cat. Fix: Return a fallback DirectMessageInfo with raw catId as label and 'unknown' as model, matching legacy behavior. D3 (same-breed warning) still correctly skipped since breed can't be determined without config. Red→Green: 3 new tests in context-assembler.test.js confirming unknown-sender fallback, self-sender null, and no-sender null. 137 F237 tests pass with no regressions. [布偶猫/Claude Opus 4.6🐾] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Lysander Su <773678591@qq.com> --- .../b1-session-bootstrap.md" | 4 + .../hook.yaml" | 26 + .../.template-ref" | 1 + .../hook.yaml" | 26 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../d12-active-participants.md" | 2 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../d14-sop-stage-hint.md" | 2 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../d16-bootcamp-mode.md" | 2 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../d2-direct-message-source.md" | 2 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../d6-teammates-context.md" | 2 + .../hook.yaml" | 25 + .../d7-mode-declaration.md" | 2 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../hook.yaml" | 24 + .../l5-mcp-index.md" | 2 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../.template-ref" | 1 + .../hook.yaml" | 24 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../hook.yaml" | 25 + .../r1-route-serial.md" | 4 + .../hook.yaml" | 25 + .../r2-route-parallel.md" | 4 + .../.template-ref" | 1 + .../hook.yaml" | 27 + .../s10-\346\212\244\346\240\217/hook.yaml" | 25 + .../s10-guardrails.md" | 2 + .../hook.yaml" | 25 + .../s11-defaults.md" | 2 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../hook.yaml" | 25 + .../s3-pack-mask.md" | 2 + .../hook.yaml" | 25 + .../s4-collaboration-format.md" | 2 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../hook.yaml" | 25 + .../s8-co-creator-reference.md" | 2 + .../.template-ref" | 1 + .../hook.yaml" | 25 + .../prompt-templates/d4-cross-thread-reply.md | 3 +- docs/ROADMAP.md | 1 + .../F237-prompt-injection-visibility.md | 683 +++++++++++++++- .../services/agents/routing/AgentRouter.ts | 6 + .../services/agents/routing/route-helpers.ts | 2 + .../services/agents/routing/route-parallel.ts | 15 + .../services/agents/routing/route-serial.ts | 15 + .../services/context/SystemPromptBuilder.ts | 514 +----------- .../src/domains/prompt-hooks/HookPipeline.ts | 197 +++++ .../src/domains/prompt-hooks/HookRegistry.ts | 148 ++++ .../prompt-hooks/PipelinePromptBuilder.ts | 246 ++++++ .../domains/prompt-hooks/assemble-bridge.ts | 229 ++++++ .../domains/prompt-hooks/context-assembler.ts | 253 ++++++ .../prompt-hooks/hook-manifest-parser.ts | 184 +++++ .../domains/prompt-hooks/resolvers/index.ts | 134 ++++ .../prompt-hooks/resolvers/layer-resolvers.ts | 62 ++ .../resolvers/session-resolvers.ts | 233 ++++++ .../resolvers/turn-resolvers-a.ts | 231 ++++++ .../resolvers/turn-resolvers-b.ts | 238 ++++++ .../domains/prompt-hooks/trace-adapters.ts | 83 ++ packages/api/src/index.ts | 7 + packages/api/src/routes/index.ts | 1 + .../api/src/routes/prompt-injection-hooks.ts | 70 +- .../src/routes/prompt-injection-manifest.ts | 285 +++++++ packages/api/src/routes/rules.ts | 41 +- packages/api/test/context-assembler.test.js | 28 + .../test/dual-path-strict-equality.test.js | 174 ++++ .../api/test/dual-path-validation.test.js | 224 ++++++ packages/api/test/helpers/fake-redis.js | 130 +++ .../api/test/hook-manifest-parser.test.js | 180 +++++ .../test/hook-pipeline-integration.test.js | 246 ++++++ packages/api/test/hook-pipeline.test.js | 246 ++++++ packages/api/test/hook-registry.test.js | 170 ++++ .../api/test/hook-resolver-registry.test.js | 57 ++ .../api/test/hook-resolvers-session.test.js | 228 ++++++ packages/api/test/hook-resolvers-turn.test.js | 361 +++++++++ .../api/test/l0-pipeline-equivalence.test.js | 148 ++++ .../api/test/pipeline-prompt-builder.test.js | 122 +++ packages/api/test/trace-adapters.test.js | 68 ++ .../transport-boundary-l0-equivalence.test.js | 204 +++++ packages/shared/src/types/index.ts | 29 + packages/shared/src/types/prompt-hook.ts | 353 ++++++++ .../settings/InjectionManifestContent.tsx | 5 +- pnpm-lock.yaml | 197 ++--- ...06-25-f237-phase2-design-review-request.md | 89 +++ ...37-phase2-implementation-review-request.md | 104 +++ scripts/generate-hook-manifests.mjs | 752 ++++++++++++++++++ 139 files changed, 8498 insertions(+), 712 deletions(-) create mode 100644 "assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/b1-session-bootstrap.md" create mode 100644 "assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/hook.yaml" create mode 100644 "assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/.template-ref" create mode 100644 "assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/hook.yaml" create mode 100644 "assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/.template-ref" create mode 100644 "assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/hook.yaml" create mode 100644 "assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/.template-ref" create mode 100644 "assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/hook.yaml" create mode 100644 "assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/.template-ref" create mode 100644 "assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/hook.yaml" create mode 100644 "assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" create mode 100644 "assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" create mode 100644 "assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/.template-ref" create mode 100644 "assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/hook.yaml" create mode 100644 "assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/d14-sop-stage-hint.md" create mode 100644 "assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/hook.yaml" create mode 100644 "assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/.template-ref" create mode 100644 "assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/hook.yaml" create mode 100644 "assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/d16-bootcamp-mode.md" create mode 100644 "assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/hook.yaml" create mode 100644 "assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/.template-ref" create mode 100644 "assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/hook.yaml" create mode 100644 "assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/.template-ref" create mode 100644 "assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/hook.yaml" create mode 100644 "assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/.template-ref" create mode 100644 "assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/hook.yaml" create mode 100644 "assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/d2-direct-message-source.md" create mode 100644 "assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/hook.yaml" create mode 100644 "assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" create mode 100644 "assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" create mode 100644 "assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/.template-ref" create mode 100644 "assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/hook.yaml" create mode 100644 "assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/.template-ref" create mode 100644 "assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/hook.yaml" create mode 100644 "assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/.template-ref" create mode 100644 "assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/hook.yaml" create mode 100644 "assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/.template-ref" create mode 100644 "assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/hook.yaml" create mode 100644 "assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/d6-teammates-context.md" create mode 100644 "assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/hook.yaml" create mode 100644 "assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/d7-mode-declaration.md" create mode 100644 "assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/hook.yaml" create mode 100644 "assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/.template-ref" create mode 100644 "assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/hook.yaml" create mode 100644 "assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/.template-ref" create mode 100644 "assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/hook.yaml" create mode 100644 "assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/.template-ref" create mode 100644 "assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/hook.yaml" create mode 100644 "assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/.template-ref" create mode 100644 "assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/hook.yaml" create mode 100644 "assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/.template-ref" create mode 100644 "assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/hook.yaml" create mode 100644 "assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/.template-ref" create mode 100644 "assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/hook.yaml" create mode 100644 "assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/hook.yaml" create mode 100644 "assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/l5-mcp-index.md" create mode 100644 "assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/.template-ref" create mode 100644 "assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/hook.yaml" create mode 100644 "assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/.template-ref" create mode 100644 "assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/hook.yaml" create mode 100644 "assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/.template-ref" create mode 100644 "assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/hook.yaml" create mode 100644 "assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/hook.yaml" create mode 100644 "assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/r1-route-serial.md" create mode 100644 "assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/hook.yaml" create mode 100644 "assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/r2-route-parallel.md" create mode 100644 "assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/.template-ref" create mode 100644 "assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/hook.yaml" create mode 100644 "assets/prompt-hooks/s10-\346\212\244\346\240\217/hook.yaml" create mode 100644 "assets/prompt-hooks/s10-\346\212\244\346\240\217/s10-guardrails.md" create mode 100644 "assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/hook.yaml" create mode 100644 "assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/s11-defaults.md" create mode 100644 "assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/.template-ref" create mode 100644 "assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/hook.yaml" create mode 100644 "assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/.template-ref" create mode 100644 "assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/hook.yaml" create mode 100644 "assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/.template-ref" create mode 100644 "assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/hook.yaml" create mode 100644 "assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/hook.yaml" create mode 100644 "assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/s3-pack-mask.md" create mode 100644 "assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/hook.yaml" create mode 100644 "assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/s4-collaboration-format.md" create mode 100644 "assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/.template-ref" create mode 100644 "assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/hook.yaml" create mode 100644 "assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/.template-ref" create mode 100644 "assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/hook.yaml" create mode 100644 "assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/.template-ref" create mode 100644 "assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/hook.yaml" create mode 100644 "assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/hook.yaml" create mode 100644 "assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/s8-co-creator-reference.md" create mode 100644 "assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/.template-ref" create mode 100644 "assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/hook.yaml" create mode 100644 packages/api/src/domains/prompt-hooks/HookPipeline.ts create mode 100644 packages/api/src/domains/prompt-hooks/HookRegistry.ts create mode 100644 packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts create mode 100644 packages/api/src/domains/prompt-hooks/assemble-bridge.ts create mode 100644 packages/api/src/domains/prompt-hooks/context-assembler.ts create mode 100644 packages/api/src/domains/prompt-hooks/hook-manifest-parser.ts create mode 100644 packages/api/src/domains/prompt-hooks/resolvers/index.ts create mode 100644 packages/api/src/domains/prompt-hooks/resolvers/layer-resolvers.ts create mode 100644 packages/api/src/domains/prompt-hooks/resolvers/session-resolvers.ts create mode 100644 packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-a.ts create mode 100644 packages/api/src/domains/prompt-hooks/resolvers/turn-resolvers-b.ts create mode 100644 packages/api/src/domains/prompt-hooks/trace-adapters.ts create mode 100644 packages/api/src/routes/prompt-injection-manifest.ts create mode 100644 packages/api/test/dual-path-strict-equality.test.js create mode 100644 packages/api/test/dual-path-validation.test.js create mode 100644 packages/api/test/helpers/fake-redis.js create mode 100644 packages/api/test/hook-manifest-parser.test.js create mode 100644 packages/api/test/hook-pipeline-integration.test.js create mode 100644 packages/api/test/hook-pipeline.test.js create mode 100644 packages/api/test/hook-registry.test.js create mode 100644 packages/api/test/hook-resolver-registry.test.js create mode 100644 packages/api/test/hook-resolvers-session.test.js create mode 100644 packages/api/test/hook-resolvers-turn.test.js create mode 100644 packages/api/test/l0-pipeline-equivalence.test.js create mode 100644 packages/api/test/pipeline-prompt-builder.test.js create mode 100644 packages/api/test/trace-adapters.test.js create mode 100644 packages/api/test/transport-boundary-l0-equivalence.test.js create mode 100644 packages/shared/src/types/prompt-hook.ts create mode 100644 review-notes/2026-06-25-f237-phase2-design-review-request.md create mode 100644 review-notes/2026-06-26-f237-phase2-implementation-review-request.md create mode 100644 scripts/generate-hook-manifests.mjs diff --git "a/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/b1-session-bootstrap.md" "b/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/b1-session-bootstrap.md" new file mode 100644 index 0000000000..8d04a9aa98 --- /dev/null +++ "b/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/b1-session-bootstrap.md" @@ -0,0 +1,4 @@ + + + +[Session Bootstrap: initialized] diff --git "a/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/hook.yaml" "b/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/hook.yaml" new file mode 100644 index 0000000000..060d3d2a84 --- /dev/null +++ "b/assets/prompt-hooks/b1-\344\274\232\350\257\235\345\274\225\345\257\274/hook.yaml" @@ -0,0 +1,26 @@ +id: B1 +name: 会话引导 +stage: session-init +order: 2100 +version: 1 +enabled: true + +# Content resolution +template: b1-session-bootstrap.md +resolver: B1BootstrapResolver + +# Dependencies +inputs: + - isNewSession + - bootstrapData + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "新会话的初始化引导信息" diff --git "a/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/.template-ref" "b/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/.template-ref" new file mode 100644 index 0000000000..075604e7dc --- /dev/null +++ "b/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/c1-mcp-callback.md diff --git "a/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/hook.yaml" "b/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/hook.yaml" new file mode 100644 index 0000000000..970ac9e81c --- /dev/null +++ "b/assets/prompt-hooks/c1-mcp-\345\233\236\350\260\203/hook.yaml" @@ -0,0 +1,26 @@ +id: C1 +name: MCP 回调 +stage: session-init +order: 2200 +version: 1 +enabled: true + +# Content resolution +template: c1-mcp-callback.md +resolver: C1McpCallbackResolver + +# Dependencies +inputs: + - mcpAvailable + - mcpCallbackContent + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: editable +transparencyTier: opt-in-view +governanceTier: auto-evolve + +# CVO-facing +userExplanation: "MCP 服务可用时注入的回调提示" diff --git "a/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/.template-ref" "b/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/.template-ref" new file mode 100644 index 0000000000..89cae7ec7a --- /dev/null +++ "b/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d1-identity-anchor.md diff --git "a/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/hook.yaml" "b/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/hook.yaml" new file mode 100644 index 0000000000..cff7e4fa90 --- /dev/null +++ "b/assets/prompt-hooks/d1-\350\272\253\344\273\275\351\224\232\345\256\232/hook.yaml" @@ -0,0 +1,25 @@ +id: D1 +name: 身份锚定 +stage: per-turn +order: 100 +version: 1 +enabled: true + +# Content resolution +template: d1-identity-anchor.md +resolver: D1IdentityAnchorResolver + +# Dependencies +inputs: + - identity + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "每轮重申身份以防偏移" diff --git "a/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/.template-ref" "b/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/.template-ref" new file mode 100644 index 0000000000..e724eda570 --- /dev/null +++ "b/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d10-critique-tag.md diff --git "a/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/hook.yaml" "b/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/hook.yaml" new file mode 100644 index 0000000000..e95c84e1d2 --- /dev/null +++ "b/assets/prompt-hooks/d10-\346\211\271\350\257\204\346\240\207\347\255\276/hook.yaml" @@ -0,0 +1,25 @@ +id: D10 +name: 批评标签 +stage: per-turn +order: 1000 +version: 1 +enabled: true + +# Content resolution +template: d10-critique-tag.md +resolver: D10CritiqueTagResolver + +# Dependencies +inputs: + - critiqueTag + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "用于标记需要批判性审视的内容" diff --git "a/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/.template-ref" "b/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/.template-ref" new file mode 100644 index 0000000000..a3ded334f1 --- /dev/null +++ "b/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d11-skill-trigger.md diff --git "a/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/hook.yaml" "b/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/hook.yaml" new file mode 100644 index 0000000000..f27321a4a8 --- /dev/null +++ "b/assets/prompt-hooks/d11-skill-\350\247\246\345\217\221/hook.yaml" @@ -0,0 +1,25 @@ +id: D11 +name: Skill 触发 +stage: per-turn +order: 1100 +version: 1 +enabled: true + +# Content resolution +template: d11-skill-trigger.md +resolver: D11SkillTriggerResolver + +# Dependencies +inputs: + - skillTrigger + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "自动触发的 skill 上下文" diff --git "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" "b/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" new file mode 100644 index 0000000000..db4e472485 --- /dev/null +++ "b/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/d12-active-participants.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" "b/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" new file mode 100644 index 0000000000..188309612a --- /dev/null +++ "b/assets/prompt-hooks/d12-\346\264\273\350\267\203\345\217\202\344\270\216\350\200\205/hook.yaml" @@ -0,0 +1,25 @@ +id: D12 +name: 活跃参与者 +stage: per-turn +order: 1200 +version: 1 +enabled: true + +# Content resolution +template: d12-active-participants.md +resolver: D12ActiveParticipantsResolver + +# Dependencies +inputs: + - activeParticipants + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前 thread 中活跃的猫猫列表" diff --git "a/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/.template-ref" "b/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/.template-ref" new file mode 100644 index 0000000000..a36b0b5dc0 --- /dev/null +++ "b/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d13-routing-policy.md diff --git "a/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/hook.yaml" "b/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/hook.yaml" new file mode 100644 index 0000000000..5f5cf7c722 --- /dev/null +++ "b/assets/prompt-hooks/d13-\350\267\257\347\224\261\347\255\226\347\225\245/hook.yaml" @@ -0,0 +1,25 @@ +id: D13 +name: 路由策略 +stage: per-turn +order: 1300 +version: 1 +enabled: true + +# Content resolution +template: d13-routing-policy.md +resolver: D13RoutingPolicyResolver + +# Dependencies +inputs: + - routingPolicy + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前的消息路由策略" diff --git "a/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/d14-sop-stage-hint.md" "b/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/d14-sop-stage-hint.md" new file mode 100644 index 0000000000..904b873f0a --- /dev/null +++ "b/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/d14-sop-stage-hint.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/hook.yaml" "b/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/hook.yaml" new file mode 100644 index 0000000000..d04676b1fe --- /dev/null +++ "b/assets/prompt-hooks/d14-sop-\351\230\266\346\256\265\346\217\220\347\244\272/hook.yaml" @@ -0,0 +1,25 @@ +id: D14 +name: SOP 阶段提示 +stage: per-turn +order: 1400 +version: 1 +enabled: true + +# Content resolution +template: d14-sop-stage-hint.md +resolver: D14SopStageResolver + +# Dependencies +inputs: + - sopStageHint + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前所处的 SOP 工作阶段" diff --git "a/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/.template-ref" "b/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/.template-ref" new file mode 100644 index 0000000000..f76b32cf1e --- /dev/null +++ "b/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d15-voice-on.md diff --git "a/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/hook.yaml" "b/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/hook.yaml" new file mode 100644 index 0000000000..45bf694de3 --- /dev/null +++ "b/assets/prompt-hooks/d15-\350\257\255\351\237\263\346\250\241\345\274\217/hook.yaml" @@ -0,0 +1,25 @@ +id: D15 +name: 语音模式 +stage: per-turn +order: 1500 +version: 1 +enabled: true + +# Content resolution +template: d15-voice-on.md +resolver: D15VoiceModeResolver + +# Dependencies +inputs: + - voiceMode + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: human-gated + +# CVO-facing +userExplanation: "语音模式开关状态和行为指导" diff --git "a/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/d16-bootcamp-mode.md" "b/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/d16-bootcamp-mode.md" new file mode 100644 index 0000000000..86dde438d0 --- /dev/null +++ "b/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/d16-bootcamp-mode.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/hook.yaml" "b/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/hook.yaml" new file mode 100644 index 0000000000..fcbea95e07 --- /dev/null +++ "b/assets/prompt-hooks/d16-bootcamp-\346\250\241\345\274\217/hook.yaml" @@ -0,0 +1,25 @@ +id: D16 +name: Bootcamp 模式 +stage: per-turn +order: 1600 +version: 1 +enabled: true + +# Content resolution +template: d16-bootcamp-mode.md +resolver: D16BootcampResolver + +# Dependencies +inputs: + - bootcampMode + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "新用户引导模式的上下文" diff --git "a/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/.template-ref" "b/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/.template-ref" new file mode 100644 index 0000000000..4d4f670224 --- /dev/null +++ "b/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d17-guide-candidate.md diff --git "a/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/hook.yaml" "b/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/hook.yaml" new file mode 100644 index 0000000000..bffa105fb1 --- /dev/null +++ "b/assets/prompt-hooks/d17-\345\274\225\345\257\274\345\200\231\351\200\211/hook.yaml" @@ -0,0 +1,25 @@ +id: D17 +name: 引导候选 +stage: per-turn +order: 1700 +version: 1 +enabled: true + +# Content resolution +template: d17-guide-candidate.md +resolver: D17GuideCandidateResolver + +# Dependencies +inputs: + - guideCandidate + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前可触发的引导流程候选" diff --git "a/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/.template-ref" "b/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/.template-ref" new file mode 100644 index 0000000000..5ac7bb33ed --- /dev/null +++ "b/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d18-world-context.md diff --git "a/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/hook.yaml" "b/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/hook.yaml" new file mode 100644 index 0000000000..4595767f05 --- /dev/null +++ "b/assets/prompt-hooks/d18-\344\270\226\347\225\214\344\270\212\344\270\213\346\226\207/hook.yaml" @@ -0,0 +1,25 @@ +id: D18 +name: 世界上下文 +stage: per-turn +order: 1800 +version: 1 +enabled: true + +# Content resolution +template: d18-world-context.md +resolver: D18WorldContextResolver + +# Dependencies +inputs: + - worldContext + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前世界/项目的环境上下文" diff --git "a/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/.template-ref" "b/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/.template-ref" new file mode 100644 index 0000000000..f26c903f0b --- /dev/null +++ "b/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d19-constitutional-knowledge.md diff --git "a/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/hook.yaml" "b/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/hook.yaml" new file mode 100644 index 0000000000..6b1953f1f7 --- /dev/null +++ "b/assets/prompt-hooks/d19-\345\256\252\346\263\225\347\237\245\350\257\206/hook.yaml" @@ -0,0 +1,25 @@ +id: D19 +name: 宪法知识 +stage: per-turn +order: 1900 +version: 1 +enabled: true + +# Content resolution +template: d19-constitutional-knowledge.md +resolver: D19ConstitutionalResolver + +# Dependencies +inputs: + - constitutionalKnowledge + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "核心治理知识和决策框架" diff --git "a/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/d2-direct-message-source.md" "b/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/d2-direct-message-source.md" new file mode 100644 index 0000000000..f0d854f002 --- /dev/null +++ "b/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/d2-direct-message-source.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/hook.yaml" "b/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/hook.yaml" new file mode 100644 index 0000000000..15e6c4e38d --- /dev/null +++ "b/assets/prompt-hooks/d2-\347\233\264\346\216\245\346\266\210\346\201\257\346\235\245\346\272\220/hook.yaml" @@ -0,0 +1,25 @@ +id: D2 +name: 直接消息来源 +stage: per-turn +order: 200 +version: 1 +enabled: true + +# Content resolution +template: d2-direct-message-source.md +resolver: D2DirectMsgResolver + +# Dependencies +inputs: + - directMessageSource + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: human-gated + +# CVO-facing +userExplanation: "标记消息是来自哪只猫或铲屎官" diff --git "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" new file mode 100644 index 0000000000..d9d802aa50 --- /dev/null +++ "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d20-signal-articles.md diff --git "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" new file mode 100644 index 0000000000..3153f9927d --- /dev/null +++ "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" @@ -0,0 +1,25 @@ +id: D20 +name: 信号文章 +stage: per-turn +order: 2000 +version: 1 +enabled: true + +# Content resolution +template: d20-signal-articles.md +resolver: D20SignalArticlesResolver + +# Dependencies +inputs: + - signalArticles + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "相关的信号/知识文章摘要" diff --git "a/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/.template-ref" "b/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/.template-ref" new file mode 100644 index 0000000000..00de9ee3bf --- /dev/null +++ "b/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/handoff-decision-tree.md diff --git "a/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/hook.yaml" "b/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/hook.yaml" new file mode 100644 index 0000000000..25652b7988 --- /dev/null +++ "b/assets/prompt-hooks/d21-\345\206\263\347\255\226\346\240\221/hook.yaml" @@ -0,0 +1,25 @@ +id: D21 +name: 决策树 +stage: per-turn +order: 2100 +version: 1 +enabled: true + +# Content resolution +template: handoff-decision-tree.md +resolver: D21DecisionTreeResolver + +# Dependencies +inputs: + - handoffDecisionTree + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "传球决策的完整判断树" diff --git "a/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/.template-ref" "b/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/.template-ref" new file mode 100644 index 0000000000..19ec57862c --- /dev/null +++ "b/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d3-same-breed-warning.md diff --git "a/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/hook.yaml" "b/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/hook.yaml" new file mode 100644 index 0000000000..bffffcefba --- /dev/null +++ "b/assets/prompt-hooks/d3-\345\220\214\346\227\217\350\255\246\345\221\212/hook.yaml" @@ -0,0 +1,25 @@ +id: D3 +name: 同族警告 +stage: per-turn +order: 300 +version: 1 +enabled: true + +# Content resolution +template: d3-same-breed-warning.md +resolver: D3SameBreedResolver + +# Dependencies +inputs: + - sameBreedWarning + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当同族猫互相 review 时给出警告" diff --git "a/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/.template-ref" "b/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/.template-ref" new file mode 100644 index 0000000000..dcc96793ec --- /dev/null +++ "b/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d4-cross-thread-reply.md diff --git "a/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/hook.yaml" "b/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/hook.yaml" new file mode 100644 index 0000000000..9b736b0928 --- /dev/null +++ "b/assets/prompt-hooks/d4-\350\267\250-thread-\345\233\236\345\244\215/hook.yaml" @@ -0,0 +1,25 @@ +id: D4 +name: 跨 Thread 回复 +stage: per-turn +order: 400 +version: 1 +enabled: true + +# Content resolution +template: d4-cross-thread-reply.md +resolver: D4CrossThreadResolver + +# Dependencies +inputs: + - crossThreadReplyHint + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: human-gated + +# CVO-facing +userExplanation: "收到跨 thread 投递时的上下文提示" diff --git "a/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/.template-ref" "b/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/.template-ref" new file mode 100644 index 0000000000..bbdc4de039 --- /dev/null +++ "b/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d5-ping-pong-warning.md diff --git "a/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/hook.yaml" "b/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/hook.yaml" new file mode 100644 index 0000000000..cefcd830c6 --- /dev/null +++ "b/assets/prompt-hooks/d5-\344\271\222\344\271\223\347\220\203\350\255\246\345\221\212/hook.yaml" @@ -0,0 +1,25 @@ +id: D5 +name: 乒乓球警告 +stage: per-turn +order: 500 +version: 1 +enabled: true + +# Content resolution +template: d5-ping-pong-warning.md +resolver: D5PingPongResolver + +# Dependencies +inputs: + - pingPongWarning + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: limited-edit +transparencyTier: visible-by-default +governanceTier: human-gated + +# CVO-facing +userExplanation: "当两只猫连续互传 ≥2 轮时警告" diff --git "a/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/d6-teammates-context.md" "b/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/d6-teammates-context.md" new file mode 100644 index 0000000000..c68f7eeecf --- /dev/null +++ "b/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/d6-teammates-context.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/hook.yaml" "b/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/hook.yaml" new file mode 100644 index 0000000000..93dd93f537 --- /dev/null +++ "b/assets/prompt-hooks/d6-\351\230\237\345\217\213\344\270\212\344\270\213\346\226\207/hook.yaml" @@ -0,0 +1,25 @@ +id: D6 +name: 队友上下文 +stage: per-turn +order: 600 +version: 1 +enabled: true + +# Content resolution +template: d6-teammates-context.md +resolver: D6TeammatesContextResolver + +# Dependencies +inputs: + - teammatesContext + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前活跃队友的实时状态" diff --git "a/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/d7-mode-declaration.md" "b/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/d7-mode-declaration.md" new file mode 100644 index 0000000000..4737be92d5 --- /dev/null +++ "b/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/d7-mode-declaration.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/hook.yaml" "b/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/hook.yaml" new file mode 100644 index 0000000000..aef6658d0f --- /dev/null +++ "b/assets/prompt-hooks/d7-\346\250\241\345\274\217\345\243\260\346\230\216/hook.yaml" @@ -0,0 +1,25 @@ +id: D7 +name: 模式声明 +stage: per-turn +order: 700 +version: 1 +enabled: true + +# Content resolution +template: d7-mode-declaration.md +resolver: D7ModeResolver + +# Dependencies +inputs: + - mode + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前的工作模式(如 voice mode、bootcamp 等)" diff --git "a/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/.template-ref" "b/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/.template-ref" new file mode 100644 index 0000000000..793df7b319 --- /dev/null +++ "b/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/a2a-ball-check.md diff --git "a/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/hook.yaml" "b/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/hook.yaml" new file mode 100644 index 0000000000..36c7e17130 --- /dev/null +++ "b/assets/prompt-hooks/d8-a2a-\347\220\203\346\235\203\346\243\200\346\237\245/hook.yaml" @@ -0,0 +1,25 @@ +id: D8 +name: A2A 球权检查 +stage: per-turn +order: 800 +version: 1 +enabled: true + +# Content resolution +template: a2a-ball-check.md +resolver: D8BallCheckResolver + +# Dependencies +inputs: + - ballCustody + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "检查当前球权状态,防止无球操作" diff --git "a/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/.template-ref" "b/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/.template-ref" new file mode 100644 index 0000000000..b8e8a32c1d --- /dev/null +++ "b/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/d9-routing-feedback.md diff --git "a/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/hook.yaml" "b/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/hook.yaml" new file mode 100644 index 0000000000..d2e4426ebb --- /dev/null +++ "b/assets/prompt-hooks/d9-\350\267\257\347\224\261\345\217\215\351\246\210/hook.yaml" @@ -0,0 +1,25 @@ +id: D9 +name: 路由反馈 +stage: per-turn +order: 900 +version: 1 +enabled: true + +# Content resolution +template: d9-routing-feedback.md +resolver: D9RoutingFeedbackResolver + +# Dependencies +inputs: + - routingFeedback + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "上一轮路由决策的反馈" diff --git "a/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/.template-ref" "b/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/.template-ref" new file mode 100644 index 0000000000..ecf69312ad --- /dev/null +++ "b/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l1-parallel-world.md diff --git "a/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/hook.yaml" "b/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/hook.yaml" new file mode 100644 index 0000000000..2bebf580f2 --- /dev/null +++ "b/assets/prompt-hooks/l1-\345\271\263\350\241\214\344\270\226\347\225\214\350\207\252\346\210\221\346\204\217\350\257\206/hook.yaml" @@ -0,0 +1,24 @@ +id: L1 +name: 平行世界自我意识 +stage: session-init +order: 100 +version: 1 +enabled: true + +# Content resolution +template: l1-parallel-world.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "告诉猫同一个身份可能在多个对话中同时存在" diff --git "a/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/.template-ref" "b/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/.template-ref" new file mode 100644 index 0000000000..ebf5f84125 --- /dev/null +++ "b/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l2-carry-over.md diff --git "a/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/hook.yaml" "b/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/hook.yaml" new file mode 100644 index 0000000000..57bd1aac51 --- /dev/null +++ "b/assets/prompt-hooks/l2-\345\256\242\350\247\202\346\200\247-carry-over-\346\256\265/hook.yaml" @@ -0,0 +1,24 @@ +id: L2 +name: 客观性 carry-over 段 +stage: session-init +order: 200 +version: 1 +enabled: true + +# Content resolution +template: l2-carry-over.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "确保猫的基础能力不因自定义 prompt 而退化" diff --git "a/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/.template-ref" "b/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/.template-ref" new file mode 100644 index 0000000000..761bccc964 --- /dev/null +++ "b/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l3-routing-rules.md diff --git "a/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/hook.yaml" "b/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/hook.yaml" new file mode 100644 index 0000000000..e50f328f63 --- /dev/null +++ "b/assets/prompt-hooks/l3-\344\274\240\347\220\203\344\270\211\351\200\211\344\270\200-\350\267\257\347\224\261\350\247\204\345\210\231/hook.yaml" @@ -0,0 +1,24 @@ +id: L3 +name: 传球三选一 + @ 路由规则 +stage: session-init +order: 300 +version: 1 +enabled: true + +# Content resolution +template: l3-routing-rules.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "规定猫每轮对话结束时必须决定球传给谁" diff --git "a/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/.template-ref" "b/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/.template-ref" new file mode 100644 index 0000000000..c9b6f7ba34 --- /dev/null +++ "b/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l4-iron-laws.md diff --git "a/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/hook.yaml" "b/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/hook.yaml" new file mode 100644 index 0000000000..c1f84588b1 --- /dev/null +++ "b/assets/prompt-hooks/l4-\344\272\224\346\235\241\351\223\201\345\276\213/hook.yaml" @@ -0,0 +1,24 @@ +id: L4 +name: 五条铁律 +stage: session-init +order: 400 +version: 1 +enabled: true + +# Content resolution +template: l4-iron-laws.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "不可违反的安全约束" diff --git "a/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/hook.yaml" "b/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/hook.yaml" new file mode 100644 index 0000000000..ff427a01a5 --- /dev/null +++ "b/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/hook.yaml" @@ -0,0 +1,24 @@ +id: L5 +name: MCP 工具 quick index +stage: session-init +order: 500 +version: 1 +enabled: true + +# Content resolution +template: l5-mcp-tools-index.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "MCP 工具家族快速索引" diff --git "a/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/l5-mcp-index.md" "b/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/l5-mcp-index.md" new file mode 100644 index 0000000000..77565fe646 --- /dev/null +++ "b/assets/prompt-hooks/l5-mcp-\345\267\245\345\205\267-quick-index/l5-mcp-index.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/.template-ref" "b/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/.template-ref" new file mode 100644 index 0000000000..8380b9acfa --- /dev/null +++ "b/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l6-capability-wakeup.md diff --git "a/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/hook.yaml" "b/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/hook.yaml" new file mode 100644 index 0000000000..a21e3bf3c9 --- /dev/null +++ "b/assets/prompt-hooks/l6-\350\203\275\345\212\233\345\224\244\351\206\222\346\214\207\345\215\227/hook.yaml" @@ -0,0 +1,24 @@ +id: L6 +name: 能力唤醒指南 +stage: session-init +order: 600 +version: 1 +enabled: true + +# Content resolution +template: l6-capability-wakeup.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "场景到 skill 的触发反射映射" diff --git "a/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/.template-ref" "b/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/.template-ref" new file mode 100644 index 0000000000..1f80df0f22 --- /dev/null +++ "b/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/l7-collaboration-philosophy.md diff --git "a/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/hook.yaml" "b/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/hook.yaml" new file mode 100644 index 0000000000..7d88449a46 --- /dev/null +++ "b/assets/prompt-hooks/l7-\345\215\217\344\275\234\345\223\262\345\255\246/hook.yaml" @@ -0,0 +1,24 @@ +id: L7 +name: 协作哲学 +stage: session-init +order: 700 +version: 1 +enabled: true + +# Content resolution +template: l7-collaboration-philosophy.md + +# Dependencies +inputs: + [] + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "伙伴猫不是工具猫" diff --git "a/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/.template-ref" "b/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/.template-ref" new file mode 100644 index 0000000000..fd8d3340c2 --- /dev/null +++ "b/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/n1-navigation.md diff --git "a/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/hook.yaml" "b/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/hook.yaml" new file mode 100644 index 0000000000..562c4e9a45 --- /dev/null +++ "b/assets/prompt-hooks/n1-\345\257\274\350\210\252\344\270\212\344\270\213\346\226\207/hook.yaml" @@ -0,0 +1,25 @@ +id: N1 +name: 导航上下文 +stage: per-turn +order: 2400 +version: 1 +enabled: true + +# Content resolution +template: n1-navigation.md +resolver: N1NavigationResolver + +# Dependencies +inputs: + - navigationContext + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "当前的导航和定位上下文" diff --git "a/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/hook.yaml" "b/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/hook.yaml" new file mode 100644 index 0000000000..3ef59a2cd6 --- /dev/null +++ "b/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/hook.yaml" @@ -0,0 +1,25 @@ +id: R1 +name: 路由组装(串行) +stage: per-turn +order: 2200 +version: 1 +enabled: true + +# Content resolution +template: r1-route-serial.md +resolver: R1RouteSerialResolver + +# Dependencies +inputs: + - routeSerialContext + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "串行路由的组装逻辑" diff --git "a/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/r1-route-serial.md" "b/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/r1-route-serial.md" new file mode 100644 index 0000000000..23987c25f0 --- /dev/null +++ "b/assets/prompt-hooks/r1-\350\267\257\347\224\261\347\273\204\350\243\205-\344\270\262\350\241\214/r1-route-serial.md" @@ -0,0 +1,4 @@ + + + +[Route Assembly: serial mode] diff --git "a/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/hook.yaml" "b/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/hook.yaml" new file mode 100644 index 0000000000..a20abec1ec --- /dev/null +++ "b/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/hook.yaml" @@ -0,0 +1,25 @@ +id: R2 +name: 路由组装(并行) +stage: per-turn +order: 2300 +version: 1 +enabled: true + +# Content resolution +template: r2-route-parallel.md +resolver: R2RouteParallelResolver + +# Dependencies +inputs: + - routeParallelContext + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "并行路由的组装逻辑" diff --git "a/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/r2-route-parallel.md" "b/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/r2-route-parallel.md" new file mode 100644 index 0000000000..bcc4570663 --- /dev/null +++ "b/assets/prompt-hooks/r2-\350\267\257\347\224\261\347\273\204\350\243\205-\345\271\266\350\241\214/r2-route-parallel.md" @@ -0,0 +1,4 @@ + + + +[Route Assembly: parallel mode] diff --git "a/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/.template-ref" "b/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/.template-ref" new file mode 100644 index 0000000000..1937c5e3c9 --- /dev/null +++ "b/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s1-identity.md diff --git "a/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/hook.yaml" "b/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/hook.yaml" new file mode 100644 index 0000000000..ef3e197a5e --- /dev/null +++ "b/assets/prompt-hooks/s1-\350\272\253\344\273\275\345\243\260\346\230\216/hook.yaml" @@ -0,0 +1,27 @@ +id: S1 +name: 身份声明 +stage: session-init +order: 800 +version: 1 +enabled: true + +# Content resolution +template: s1-identity.md +resolver: S1IdentityResolver + +# Dependencies +inputs: + - displayName + - roleDescription + - identity + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "猫的身份、角色和昵称" diff --git "a/assets/prompt-hooks/s10-\346\212\244\346\240\217/hook.yaml" "b/assets/prompt-hooks/s10-\346\212\244\346\240\217/hook.yaml" new file mode 100644 index 0000000000..721f45ee12 --- /dev/null +++ "b/assets/prompt-hooks/s10-\346\212\244\346\240\217/hook.yaml" @@ -0,0 +1,25 @@ +id: S10 +name: 护栏 +stage: session-init +order: 1700 +version: 1 +enabled: true + +# Content resolution +template: s10-guardrails.md +resolver: S10GuardrailsResolver + +# Dependencies +inputs: + - guardrails + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "安全护栏和行为边界" diff --git "a/assets/prompt-hooks/s10-\346\212\244\346\240\217/s10-guardrails.md" "b/assets/prompt-hooks/s10-\346\212\244\346\240\217/s10-guardrails.md" new file mode 100644 index 0000000000..daa273852a --- /dev/null +++ "b/assets/prompt-hooks/s10-\346\212\244\346\240\217/s10-guardrails.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/hook.yaml" "b/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/hook.yaml" new file mode 100644 index 0000000000..a612d429c3 --- /dev/null +++ "b/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/hook.yaml" @@ -0,0 +1,25 @@ +id: S11 +name: 默认行为 +stage: session-init +order: 1800 +version: 1 +enabled: true + +# Content resolution +template: s11-defaults.md +resolver: S11DefaultsResolver + +# Dependencies +inputs: + - defaults + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "未明确指定时的默认行为设置" diff --git "a/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/s11-defaults.md" "b/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/s11-defaults.md" new file mode 100644 index 0000000000..8971b0678c --- /dev/null +++ "b/assets/prompt-hooks/s11-\351\273\230\350\256\244\350\241\214\344\270\272/s11-defaults.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/.template-ref" "b/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/.template-ref" new file mode 100644 index 0000000000..0a1a4b9d9b --- /dev/null +++ "b/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s12-world-driver.md diff --git "a/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/hook.yaml" "b/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/hook.yaml" new file mode 100644 index 0000000000..c251378005 --- /dev/null +++ "b/assets/prompt-hooks/s12-\344\270\226\347\225\214\351\251\261\345\212\250/hook.yaml" @@ -0,0 +1,25 @@ +id: S12 +name: 世界驱动 +stage: session-init +order: 1900 +version: 1 +enabled: true + +# Content resolution +template: s12-world-driver.md +resolver: S12WorldDriverResolver + +# Dependencies +inputs: + - worldDriver + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "世界观和环境驱动的行为设置" diff --git "a/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/.template-ref" "b/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/.template-ref" new file mode 100644 index 0000000000..9b127d481f --- /dev/null +++ "b/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/mcp-tools.md diff --git "a/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/hook.yaml" "b/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/hook.yaml" new file mode 100644 index 0000000000..ca36734116 --- /dev/null +++ "b/assets/prompt-hooks/s13-mcp-\345\267\245\345\205\267\346\226\207\346\241\243/hook.yaml" @@ -0,0 +1,25 @@ +id: S13 +name: MCP 工具文档 +stage: session-init +order: 2000 +version: 1 +enabled: true + +# Content resolution +template: mcp-tools.md +resolver: S13McpToolsResolver + +# Dependencies +inputs: + - mcpDocs + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: editable +transparencyTier: opt-in-view +governanceTier: auto-evolve + +# CVO-facing +userExplanation: "MCP 工具的使用文档和示例" diff --git "a/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/.template-ref" "b/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/.template-ref" new file mode 100644 index 0000000000..a85109c568 --- /dev/null +++ "b/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s2-restrictions.md diff --git "a/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/hook.yaml" "b/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/hook.yaml" new file mode 100644 index 0000000000..76d6444422 --- /dev/null +++ "b/assets/prompt-hooks/s2-\351\231\220\345\210\266\345\243\260\346\230\216/hook.yaml" @@ -0,0 +1,25 @@ +id: S2 +name: 限制声明 +stage: session-init +order: 900 +version: 1 +enabled: true + +# Content resolution +template: s2-restrictions.md +resolver: S2RestrictionsResolver + +# Dependencies +inputs: + - restrictions + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "猫不能做什么(如暹罗猫禁止写代码)" diff --git "a/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/hook.yaml" "b/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/hook.yaml" new file mode 100644 index 0000000000..2fa55f321b --- /dev/null +++ "b/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/hook.yaml" @@ -0,0 +1,25 @@ +id: S3 +name: Pack Mask(能力覆盖) +stage: session-init +order: 1000 +version: 1 +enabled: true + +# Content resolution +template: s3-pack-mask.md +resolver: S3PackMaskResolver + +# Dependencies +inputs: + - packMasks + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "Pack 级别的能力遮罩声明" diff --git "a/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/s3-pack-mask.md" "b/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/s3-pack-mask.md" new file mode 100644 index 0000000000..b925bf0ab3 --- /dev/null +++ "b/assets/prompt-hooks/s3-pack-mask-\350\203\275\345\212\233\350\246\206\347\233\226/s3-pack-mask.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/hook.yaml" "b/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/hook.yaml" new file mode 100644 index 0000000000..db9a20adbc --- /dev/null +++ "b/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/hook.yaml" @@ -0,0 +1,25 @@ +id: S4 +name: 协作格式 +stage: session-init +order: 1100 +version: 1 +enabled: true + +# Content resolution +template: s4-collaboration-format.md +resolver: S4CollabFormatResolver + +# Dependencies +inputs: + - collaborationStyle + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "猫猫之间的协作格式要求" diff --git "a/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/s4-collaboration-format.md" "b/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/s4-collaboration-format.md" new file mode 100644 index 0000000000..cde09ca3a6 --- /dev/null +++ "b/assets/prompt-hooks/s4-\345\215\217\344\275\234\346\240\274\345\274\217/s4-collaboration-format.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/.template-ref" "b/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/.template-ref" new file mode 100644 index 0000000000..688e3ebf96 --- /dev/null +++ "b/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s5-teammate-roster.md diff --git "a/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/hook.yaml" "b/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/hook.yaml" new file mode 100644 index 0000000000..c66e0d5af3 --- /dev/null +++ "b/assets/prompt-hooks/s5-\351\230\237\345\217\213\350\212\261\345\220\215\345\206\214/hook.yaml" @@ -0,0 +1,25 @@ +id: S5 +name: 队友花名册 +stage: session-init +order: 1200 +version: 1 +enabled: true + +# Content resolution +template: s5-teammate-roster.md +resolver: S5TeammateRosterResolver + +# Dependencies +inputs: + - teamStrengths + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "所有队友的能力、昵称和注意事项" diff --git "a/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/.template-ref" "b/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/.template-ref" new file mode 100644 index 0000000000..466c3306a5 --- /dev/null +++ "b/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/workflow-triggers.yaml diff --git "a/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/hook.yaml" "b/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/hook.yaml" new file mode 100644 index 0000000000..d14be21df2 --- /dev/null +++ "b/assets/prompt-hooks/s6-\345\267\245\344\275\234\346\265\201\350\247\246\345\217\221\347\202\271/hook.yaml" @@ -0,0 +1,25 @@ +id: S6 +name: 工作流触发点 +stage: session-init +order: 1300 +version: 1 +enabled: true + +# Content resolution +template: workflow-triggers.yaml +resolver: S6WorkflowTriggersResolver + +# Dependencies +inputs: + - workflowTriggers + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: editable +transparencyTier: visible-by-default +governanceTier: auto-evolve + +# CVO-facing +userExplanation: "完成某步后主动 @ 下一步猫的触发点列表" diff --git "a/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/.template-ref" "b/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/.template-ref" new file mode 100644 index 0000000000..ec606d5cff --- /dev/null +++ "b/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s7-pack-workflows.md diff --git "a/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/hook.yaml" "b/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/hook.yaml" new file mode 100644 index 0000000000..5c5aeb2edc --- /dev/null +++ "b/assets/prompt-hooks/s7-pack-\345\267\245\344\275\234\346\265\201/hook.yaml" @@ -0,0 +1,25 @@ +id: S7 +name: Pack 工作流 +stage: session-init +order: 1400 +version: 1 +enabled: true + +# Content resolution +template: s7-pack-workflows.md +resolver: S7PackWorkflowsResolver + +# Dependencies +inputs: + - packWorkflows + +# Override constraints +disableable: true + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: human-gated + +# CVO-facing +userExplanation: "Pack 特有的工作流步骤" diff --git "a/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/hook.yaml" "b/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/hook.yaml" new file mode 100644 index 0000000000..d7847adcad --- /dev/null +++ "b/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/hook.yaml" @@ -0,0 +1,25 @@ +id: S8 +name: 铲屎官参考 +stage: session-init +order: 1500 +version: 1 +enabled: true + +# Content resolution +template: s8-co-creator-reference.md +resolver: S8CoCreatorRefResolver + +# Dependencies +inputs: + - coCreatorReference + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable + +# CVO-facing +userExplanation: "铲屎官的偏好和沟通风格" diff --git "a/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/s8-co-creator-reference.md" "b/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/s8-co-creator-reference.md" new file mode 100644 index 0000000000..83d2e580a7 --- /dev/null +++ "b/assets/prompt-hooks/s8-\351\223\262\345\261\216\345\256\230\345\217\202\350\200\203/s8-co-creator-reference.md" @@ -0,0 +1,2 @@ + + diff --git "a/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/.template-ref" "b/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/.template-ref" new file mode 100644 index 0000000000..5e3384f5c2 --- /dev/null +++ "b/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/.template-ref" @@ -0,0 +1 @@ +# Phase 1 template: assets/prompt-templates/s9-governance-digest.md diff --git "a/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/hook.yaml" "b/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/hook.yaml" new file mode 100644 index 0000000000..de5c7063e3 --- /dev/null +++ "b/assets/prompt-hooks/s9-\346\262\273\347\220\206\346\221\230\350\246\201/hook.yaml" @@ -0,0 +1,25 @@ +id: S9 +name: 治理摘要 +stage: session-init +order: 1600 +version: 1 +enabled: true + +# Content resolution +template: s9-governance-digest.md +resolver: S9GovernanceDigestResolver + +# Dependencies +inputs: + - governanceDigest + +# Override constraints +disableable: false + +# Classification (Phase 1 3-axis) +safetyTier: readonly +transparencyTier: opt-in-view +governanceTier: immutable + +# CVO-facing +userExplanation: "当前的治理规则和决策记录摘要" diff --git a/assets/prompt-templates/d4-cross-thread-reply.md b/assets/prompt-templates/d4-cross-thread-reply.md index d54a3844aa..42c2519621 100644 --- a/assets/prompt-templates/d4-cross-thread-reply.md +++ b/assets/prompt-templates/d4-cross-thread-reply.md @@ -1,7 +1,8 @@ - + 📨 来自跨线程消息(source thread: {{SOURCE_THREAD}},发件猫: @{{SENDER_CAT}}){{EFFECT_LABEL}} 回复请用 cross_post_message(threadId="{{SOURCE_THREAD}}", targetCats=["{{SENDER_CAT}}"]) 本 thread 的 @{{SENDER_CAT}} 不会路由回对方(对方 session 在另一个 thread) +{{CONSTRAINT_TEXT}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 538c3df69a..1e96925577 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -76,6 +76,7 @@ created: 2026-02-26 | F231 | 启动胶囊 — per-user 画像注入与 L0 分层(猫醒来第一眼看到主人:profile capsule + relationship primer + breed/instance/user/relationship 四层,养成护城河机制本体)| in-progress | Ragdoll Fable-5 | internal (operator 2026-06-11 "我同意立项的") | [F231](features/F231-user-profile-capsule.md) | | F232 | Thread Artifacts Panel — Thread 产物视图(点开 thread 就看到它产生的所有产物:图/文件/代码PR/语音聚合 + 类型筛选 + 搜索 + 跳回原消息;A thread 内抽屉先行,B 全局产物中心未来扩展)| spec | Ragdoll Opus-4.8 | internal (operator 2026-06-11 "我觉得ok了 你立项") | [F232](features/F232-thread-artifacts-panel.md) | | F233 | Ball Custody Observability — 球权保管链可观测(值班简报:operator 收件箱+死球/睡美人/虚空告警,异常优先;feat 轨迹下钻;安乐死通道;A 简报 MVP / B 心跳+探针回执 / C 安乐死+轨迹)| in-progress(A✅ B✅ 2026-06-18 · C pending operator signoff)| Ragdoll Fable-5(spec)→ opus 家族(实现) | internal (operator 2026-06-12 "走起!喵"+"① 立项") | [F233](features/F233-ball-custody-observability.md) | +| F237 | Prompt Injection Visibility — 系统 prompt 注入可见性(Phase 1 生命周期查看器+模板提取+3 段编辑器 PR#859 done;Phase 2 hook pipeline migration + trace bridging in-progress;Phase 2-D override store deferred to PR 3)| in-progress (Phase 1 ✅ Phase 2 🚧) | Ragdoll Opus 4.6 | internal (operator 2026-06-02) | [F237](features/F237-prompt-injection-visibility.md) | | F241 | Agent Provider Plugin / Hostable Provider Runtime — 外部 agent runtime 以 plugin 声明式接入(新增 agentProvider 资源类型,provider 实现移出 core;不再改 ClientId union + index.ts switch);Phase A host transport registry(F143/F161 lineage)/ B F202 agentProvider manifest / C clowder-code reference;安全边界全 host-owned(token/MCP/sandbox),F129 继承;core 安全 + merge-gate maintainer 守 | spec | community 彭潇(bouillipx) + Ragdoll家族 maintainer | community [#941](https://github.com/zts212653/clowder-ai/issues/941) | [F241](features/F241-agent-provider-plugin.md) | | F242 | Code Graph Layer Spike — 内生「约定层关联图」(Phase A/B spike 已落 main:convention-graph package + discovery skill + deer-flow skeleton;operator 2026-06-18 撤回 full close:仍缺猫猫认知路径 / 可用入口 / 更新或重建索引行为;进入 Phase C productization gate) | in-progress (spike done) | Maine Coon (gpt-5.5) + opus-48 design | internal | [F242](features/F242-code-graph-layer-spike.md) | | F243 | Docs Discovery Profile — OKF-inspired metadata + generated index(让 docs/features 从平铺文件堆变成可渐进探索的知识入口;4 Phase: stratified spike + profile draft + eval rubric → contract + lint + generator → rollout + checked-in index.md + sync gate → eval report + decisions/research 扩展 go/no-go;operator 2026-06-17 signoff;Maine Coon (gpt-5.5) co-design + R1 reviewer;F236 姊妹哲学 anchor-and-drill 调用侧 vs 文档侧;schema self-contained 供未来 consumer(F186 等是潜在候选但不绑定);防"小猫代偿决策"反模式:抽查不可代 gate) | in-progress (B-0 merged 2026-06-30, PR #2693) | Ragdoll (Ragdoll Opus-4.7) | internal (operator 2026-06-17) | [F243](features/F243-docs-discovery-profile.md) | diff --git a/docs/features/F237-prompt-injection-visibility.md b/docs/features/F237-prompt-injection-visibility.md index 14a2917a20..f9c583b14c 100644 --- a/docs/features/F237-prompt-injection-visibility.md +++ b/docs/features/F237-prompt-injection-visibility.md @@ -1,15 +1,16 @@ --- feature_ids: [F237] related_features: [F203, F153, F180, F190, F199, F206] -topics: [system-prompt, injection, visibility, console, settings, trust, governance] +topics: [system-prompt, injection, visibility, console, settings, trust, governance, hook-pipeline, lifecycle, trace] doc_kind: spec created: 2026-06-02 -updated: 2026-06-22 +updated: 2026-06-25 +user_journey_exempt: "Phase 2 is internal pipeline migration (HookRegistry, HookPipeline, resolvers). User-visible Console UI comes in Phase 3." --- # Prompt Injection Visibility -> **Status**: done — clowder-ai#859 absorbed via cat-cafe#2505 squash `b859eb38` (2026-06-22) | **Owner**: community @mindfn + cat-cafe maintainers +> **Status**: Phase 1 done (clowder-ai#859 via cat-cafe#2505 `b859eb38`) · Phase 2 design in progress | **Owner**: Ragdoll Opus 4.6 > **Issue**: [#839](https://github.com/zts212653/clowder-ai/issues/839) > **Feature ID**: F237 (assigned by maintainer; branch/PR retain original naming) @@ -123,12 +124,669 @@ Modal showing assembled prompt per cat, labeled "approximate". Selectable by cat - [x] AC-9: Per-cat dimension selector - [x] AC-10: Malformed YAML overlay graceful fallback +## What — Phase 2: Hook Pipeline + Injection Trace + +> **Upstream status**: PROPOSAL — not yet accepted by maintainer. Upstream accepted scope is PR #859 (Phase 1) + #983 (hook demotion). This Phase 2 design requires a new pitch on issue #839 to get lifecycle abstraction accepted. The ACs and landing order below describe the proposed implementation, contingent on upstream alignment. See [Upstream Pitch Strategy](#upstream-pitch-strategy-issue-839) for how we plan to address the maintainer's prior concerns. + +### Motivation + +Phase 1 delivered visibility — operators can see what's injected. Phase 2 makes **46 content segments** self-contained, dynamically manageable, observable, and versionable via a hook pipeline. The remaining 3 segments (N2 conversation history delta, M1-M2 transport-layer) have observe-only trace adapter APIs (code + unit tests delivered); production call-site wiring is deferred due to execution order constraints (N2 assembled after trace collection point; M1-M2 assembled in invocation layer). Together, the design targets 49 of 52 segments traceable — 46 via pipeline, 3 via observe-only adapters once wired. (H1-H3 Claude Code hooks are out of Phase 2/3 scope — they use a completely different injection system and will be tracked separately.) + +**Why 46:** All segments that follow the condition → content → inject pattern become full hooks. This includes: +- **S/D segments** (34): the original `if/push` patterns in `SystemPromptBuilder.ts` +- **L1-L7** (7): dynamically compiled from template files by `compile-system-prompt-l0.mjs` at runtime — NOT build-time static. Same template → render → inject pattern as S-segments, just delivered via native L0 channel for native providers +- **B1, C1** (2): session bootstrap and MCP callback — standard condition → content pattern +- **R1-R2, N1** (3): route-layer assembly and navigation context + +**Why 3 stay observe-only:** +- **N2** (conversation history delta): immutable data assembly (`trigger: always`, `disableable: false`, `governanceTier: immutable`). Just "previous unread messages" — no customization value in hook-ifying it +- **M1-M2** (transport-layer): deliberately outside content pipeline to preserve the produced-vs-delivered boundary + +**Out of Phase 2/3 scope:** +- **H1-H3** (Claude Code hooks): completely different injection system (`.claude/hooks/` shell scripts triggered by Claude Code lifecycle events — SessionStart, PostCompact, SessionStop). Injection via event stdout → tool_result, not content pipeline. H3 explicitly "不进 model prompt". These are managed by Claude Code's hook infrastructure, not Cat Cafe's content pipeline — tracked separately as **F237-H** (to be filed as issue; dependency: Phase 2 delivers trace infrastructure that H1-H3 observability can reuse) + +> **Scope note:** The original motivating incident (opus47 dragged off-task by startup hook) may involve H1 (SessionStart hook). Phase 2 addresses Cat Cafe content pipeline visibility (49/52 segments). If the incident trigger was an H1-H3 hook, full closure requires F237-H delivery. Phase 2's trace schema and persistence layer are designed to be reusable by F237-H + +### Why Hook Pipeline + +The current `SystemPromptBuilder` assembles segments via manual `if/push` patterns: + +```typescript +/* @segment D5 */ if (context.pingPongWarning) { + const d5 = renderSegment('D5', vars); + if (d5) lines.push(d5); +} +``` + +This pattern has served well for 52 segments, but makes several operations hard: + +| Operation | Current Cost | With Hook Pipeline | +|-----------|-------------|-------------------| +| Disable a segment | Find code, comment out, deploy | `enabled: false` in hook.yaml, deploy | +| Try a new version | Branch + code change + PR | Add v2 template, switch version in hook.yaml | +| Roll back | Revert commit + deploy | Revert version in hook.yaml | +| Know what fired | Read source + infer from logs | InjectionTrace record per turn | +| Add a new segment | Write code + template + manifest + tests | Template + manifest entry | +| Remove a segment | Find and delete code + template | `enabled: false`, then delete at leisure | + +**This is not "freezing dynamic injections into static claims"** — this is making dynamic injections *declaratively manageable*. The YAML manifest describes registration metadata (stage, enabled, version, dependencies), not content policy. Content lives in templates and code resolvers, exactly as today. The difference: lifecycle operations (enable/disable/version/trace) become data operations, not code operations. + +**Why this makes Build-to-Delete easier, not harder**: The maintainer's concern was that metadata turns deletion into deprecation. The opposite is true — currently, deleting a segment requires finding all code paths (condition, variable setup, render call, push), verifying no side effects, removing the template, updating the manifest display entry, and testing. With hooks: set `enabled: false`, the segment stops firing immediately. The code and template can be deleted at leisure in a cleanup pass, or left dormant with zero runtime cost. Build-to-Delete becomes a config toggle followed by optional cleanup. + +**Why this is the foundation for "injections grow from trajectories"**: The maintainer wants injections to grow organically from per-user taste, cross-thread repetition signal, and CVO correction. For that, the system needs to: +1. **Trace** which segments fired per turn and what content they produced +2. **Correlate** segment combinations with turn outcomes +3. **Iterate** — try new versions, compare, promote or demote + +Without a hook pipeline, there's no structured trace data, no version identity, and no way to correlate "segment X contributed to outcome Y." The hook pipeline is the measurement substrate that trajectory-based growth requires. + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ContextAssembler │ +│ (centralized IO: queries stores, builds typed AssemblerInput) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ AssemblerInput + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Runtime Content Pipeline (46 hooks) │ +│ │ +│ session-init S1-S13, B1, C1, L1-L7 (identity + rules) │ +│ per-turn D1-D21, R1-R2, N1 (context + routing) │ +│ │ +│ Each hook: condition → resolve → render │ +│ → emit PromptPatch + TraceEvent │ +└──────────────────────────┬──────────────────────────────────────┘ + │ PromptPatch[] + TraceEvent[] + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Transport Assembly │ +│ (OUTSIDE pipeline — independent injection mechanics) │ +│ │ +│ injectSystemPrompt decision (resume/force/registryChanged) │ +│ stagingPrepend (ADR-038, every-turn) │ +│ contextHintPrefix (F225, every-turn) │ +│ missionPrefix (F070, external project dispatch) │ +│ M2 transcriptPathHints (always appended) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Surface Trace Adapters (observe only — 3 segments) │ +│ │ +│ N2 conversation history delta (immutable data assembly) │ +│ M1-M2 transport-layer (missionPrefix, transcriptPathHints) │ +│ │ +│ No resolvers, no enable/disable, no versioning. │ +│ Only emit TraceEvents for observability. │ +│ │ +│ (H1-H3 Claude Code hooks: out of scope — tracked separately) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Two Tiers: Runtime Content Pipeline + Surface Trace Adapters + +Phase 2 targets 49 of 52 segments in two tiers (H1-H3 Claude Code hooks are out of scope — tracked separately). Tier 1 (46 segments) is fully operational via HookPipeline. Tier 2 (3 segments) has adapter API + unit tests; production call-site wiring is deferred (see AC-P2-13): + +**Tier 1 — Runtime Content Pipeline (46 segments)** + +These segments follow the condition → content → inject pattern and benefit from full hook-ification: manifest, resolver, versioning, trace. The pipeline has two stages (runtime override store added in PR 3): + +| Stage | When | Segments | Source | +|-------|------|----------|--------| +| `session-init` | New session / re-injection / registry change | S1-S13, B1, C1, L1-L7 (22 hooks) | `buildStaticIdentity()`, `SessionBootstrap`, `McpPromptInjector`, `compile-system-prompt-l0.mjs` | +| `per-turn` | Every invocation, before model call | D1-D21, R1-R2, N1 (24 hooks) | `buildInvocationContext()`, route layer, `route-helpers.ts` | + +Why these segments unify: +- **S1-S13, D1-D21** (34): original `if/push` patterns in `SystemPromptBuilder.ts` — the core use case +- **L1-L7** (7): dynamically compiled from `assets/prompt-templates/l*.md` template files at runtime by `compileL0()`. Same template → render → inject pattern as S-segments. Delivery channel = `native-l0` for native providers. The L0 compiler's content source is refactored: instead of independently loading template files, it consumes pipeline-produced output for L1-L7. The delivery mechanism (`--system-prompt-file`, native L0 channel) is preserved unchanged +- **B1** (1): session bootstrap — condition (new session?) → content. Joins `session-init` +- **C1** (1): MCP callback — condition (MCP available?) → content. Joins `session-init` _(`.local` overlay migration to override store deferred to PR 3)_ +- **R1-R2** (2): route assembly — condition → content at route layer. Joins `per-turn` +- **N1** (1): navigation context — condition → content. Joins `per-turn` + +Hook contract: +- **Input**: `AssemblerInput` — typed context data gathered by ContextAssembler +- **Output**: `PromptPatch` (rendered content) + `TraceEvent` (observability record) + +**Tier 2 — Surface Trace Adapters (3 segments: N2, M1-M2)** + +These segments are genuinely different from the hook pipeline pattern: + +| Surface | Source | Why observe-only | +|---------|--------|-----------------| +| N2 (1) | `route-helpers.ts` | **Immutable data assembly**. Conversation history delta = "previous unread messages." `trigger: always`, `disableable: false`, `governanceTier: immutable`. No customization value — not something you'd version, disable, or template-ify | +| M1 (1) | `invoke-single-cat.ts` | **Transport-layer** (missionPrefix, F070). Deliberately outside content pipeline to preserve produced-vs-delivered boundary | +| M2 (1) | `invoke-single-cat.ts` | **Transport-layer** (transcriptPathHints). Always appended, always delivered. Same reason as M1 | + +Total: 1 + 2 = **3 segments**. Combined with Tier 1's 46 hooks = **49 segments** in Phase 2 scope. (H1-H3, 3 segments, tracked separately — see [Out of scope](#what-phase-2-does-not-include).) + +Trace adapters have no resolvers, no enable/disable toggle, no versioning. They only produce `TraceEventObserved`. + +### HookManifest — Self-Contained Segment Definition + +Each hook is defined by a YAML manifest (registration metadata) + optional code resolver (condition + variable setup) + template file (content). Following the `PluginRegistry` pattern (F202): + +```yaml +# assets/prompt-hooks/D5-ping-pong-warning/hook.yaml +id: D5 +name: 乒乓球警告 +stage: per-turn +order: 500 # execution order within stage (see below) +version: 1 +enabled: true + +# Content resolution +template: d5-ping-pong-warning.md # existing Phase 1 template +resolver: D5PingPongResolver # code resolver class name (optional) + +# Dependencies — what AssemblerInput fields this hook reads +inputs: + - pingPongWarning # field name on AssemblerInput + +# Override constraints +disableable: true # false = override store rejects disable (S1, D8, L1-L7 etc.) + +# Classification (Phase 1 3-axis, carried forward) +safetyTier: limited-edit # readonly | limited-edit | editable — gates template override +transparencyTier: visible-by-default +governanceTier: human-gated # immutable | human-gated | auto-evolve — gates version override + +# CVO-facing +userExplanation: "当两只猫连续互传 ≥2 轮时警告,避免死循环" +``` + +**Key properties:** +- `id` — stable segment identifier (S1, D5, etc.), matches Phase 1 manifest +- `stage` — which clock signal triggers this hook +- `order` — integer, determines execution order within a stage. Lower = earlier in the compiled prompt. Built-in hooks use 100-step spacing (S1=100, S2=200, ..., D1=100, D2=200, ...) to leave room for future insertions without reordering existing hooks. The order directly maps to the current `if/push` sequence in `SystemPromptBuilder.ts` — same output order, same model behavior. Order is per-stage (session-init and per-turn each start from their own sequence). Not overridable at runtime — order is a structural property of the prompt, not a user-tunable knob +- `version` — integer, enables v1→v2 migration without deleting v1 +- `enabled` — boolean, the Build-to-Delete toggle +- `template` — path to content template (reuses Phase 1 extracted templates) +- `resolver` — optional TypeScript class that evaluates condition and prepares template variables. Hooks without a resolver are unconditional (always fire when stage fires) +- `inputs` — declares which `AssemblerInput` fields the resolver reads. Enables dependency analysis and makes each hook's data requirements explicit + +**Migration from Phase 1:** Each of the 46 pipelined segments becomes a `hook.yaml` + its existing template file. For S/D segments, the resolver code is extracted from the inline `if/push` pattern. For L1-L7, the existing template files (`l1-parallel-world.md` etc.) become hook templates; the L0 compiler's content source switches from direct template loading to pipeline-produced output (delivery channel unchanged). For B1/C1/R1-R2/N1, resolvers wrap existing execution logic. Zero content change, zero behavior change — same transformation principle as Phase 1's template extraction. The 3 observe-only segments (N2, M1-M2) are not migrated into the hook directory. (H1-H3 are out of Phase 2 scope.) + +### HookRegistry — Scan, Register, Resolve + +Modeled on `PluginRegistry` (scan directory, parse manifests, validate, derive status): + +```typescript +interface HookRegistry { + /** Scan hook directory, parse manifests, validate, register */ + scan(): HookManifest[]; + + /** Get hooks for a specific stage, ascending by `order` field */ + getStageHooks(stage: HookStage): RegisteredHook[]; + + /** Get single hook by ID */ + getHook(hookId: string): RegisteredHook | undefined; + + /** All registered hooks */ + getAllHooks(): RegisteredHook[]; + + /** Enabled state from manifest baseline */ + isEnabled(hookId: string): boolean; + + /** Active version from manifest baseline */ + getActiveVersion(hookId: string): number; + + // PR 3 adds: getEffective(hookId) → EffectiveHookState + // (runtime override ?? manifest baseline resolution chain) +} + +interface RegisteredHook { + manifest: HookManifest; // baseline, from repo YAML + resolver: HookResolver | null; // null = unconditional, always fires + template: string; // baseline template content +} +``` + +**State model — manifest baseline (PR 2) + runtime override (PR 3):** + +> _The following describes the full two-layer design. PR 2 implements manifest baseline only; the runtime override layer is deferred to PR 3._ + +Hook state uses a two-layer resolution chain: **runtime override** (Redis-persisted, PR 3) takes precedence over **manifest baseline** (git-tracked YAML, PR 2). This separates product-level concerns from user-level concerns: + +``` +Effective state = runtime override ?? manifest baseline +``` + +| Layer | What it controls | Who changes it | How | +|-------|-----------------|----------------|-----| +| **Manifest baseline** | Which hooks exist, default templates, default enabled/version | Product team | git commit + deploy | +| **Runtime override** | Enable/disable, version switch, template edits, auto-eval iterations | Operator / auto-eval | Console API / auto-eval API | + +**Why two layers:** +- **Baseline** is the product's factory default — it defines which hooks ship and their default behavior. Adding or removing a built-in hook is a product-level change that goes through git. This is the "file + git + restart" channel. +- **Runtime override** is the user's workspace — operators can disable hooks they don't want, edit templates to customize behavior, switch versions. Auto-eval (Phase 3) writes to the same override layer. Override and auto-eval use the same mechanism; there's no distinction between manual and automated customization. +- **Package-install users** never touch git. Their entire interaction is through runtime overrides on top of the shipped baseline. + +**Safety boundary — three override constraints:** + +| Override type | Gated by | Rule | +|---------------|----------|------| +| **Template edit** | `safetyTier` | `readonly` (49/52) reject, `limited-edit` / `editable` (3/52) accept | +| **Enable/disable** | `disableable` | `disableable: false` hooks (identity, safety, routing constraints) reject disable override; `disableable: true` hooks accept | +| **Version switch** | `governanceTier` | `immutable` hooks reject version override; `human-gated` / `auto-evolve` accept | + +The override store validates all three constraints before accepting a write. Attempting to disable a `disableable: false` hook (e.g., S1 identity, D8 ball ownership, L1-L7 core rules) returns an error with the constraint violation. + +**Runtime override store:** + +```typescript +interface HookOverrideStore { + /** Get override for a hook (null = use baseline) */ + getOverride(hookId: string): HookOverride | null; + + /** Set override (operator or auto-eval). Validates constraints: + * - enabled=false rejected if manifest.disableable=false + * - templateContent rejected if manifest.safetyTier='readonly' + * - version rejected if manifest.governanceTier='immutable' + * Throws OverrideConstraintError on violation. */ + setOverride(hookId: string, override: HookOverride): void; + + /** Clear override (revert to baseline) */ + clearOverride(hookId: string): void; + + /** List all active overrides */ + listOverrides(): Array<{ hookId: string; override: HookOverride }>; +} + +interface HookOverride { + enabled?: boolean; // override enabled state + version?: number; // override active version + templateContent?: string; // override template (safetyTier gated) + source: 'operator' | 'auto-eval'; + updatedAt: number; + reason?: string; // why this override was set +} +``` + +Keyed by `hook-override:{hookId}`. TTL=0 (persistent) — user customizations must survive restart (LL-048). Audit trail: each override records `source` and `reason`, enabling "who changed this and why" queries. + +**Directory structure:** + +``` +assets/prompt-hooks/ +├── S1-identity/ +│ ├── hook.yaml +│ └── s1-identity.md # existing Phase 1 template (symlink or move) +├── D5-ping-pong-warning/ +│ ├── hook.yaml +│ ├── d5-ping-pong-warning.md +│ └── d5-ping-pong-warning.v2.md # version 2 template (future) +├── D8-a2a-ball-check/ +│ ├── hook.yaml +│ └── a2a-ball-check.md +└── ... +``` + +### ContextAssembler — Centralized IO + +Today, `buildInvocationContext()` receives a 30+ field `InvocationContext` bag where each field is consumed by exactly one segment (e.g., `pingPongWarning` → D5, `crossThreadReplyHint` → D4). The data comes from route-layer queries scattered across `route-serial.ts`, `route-parallel.ts`, and `route-helpers.ts`. + +ContextAssembler centralizes this: + +```typescript +interface ContextAssembler { + /** + * Gather all inputs needed by active hooks for this stage. + * Route-layer calls this once; hooks never do their own store queries. + */ + assemble(stage: HookStage, baseContext: BaseContext): Promise; +} + +/** BaseContext = what the route layer already has (catId, threadId, userId, sessionId, etc.) */ +interface BaseContext { + catId: CatId; + threadId: string; + userId: string; + sessionId: string | null; + dispatch: EffectiveDispatch; + // ... other route-layer provided values +} + +/** AssemblerInput = typed bag of everything hooks might need */ +interface AssemblerInput extends BaseContext { + // Session-init stage inputs + catConfig: CatConfig; + mcpAvailable: boolean; + packBlocks: PackBlocks | null; + callableMentions: MentionInfo; + + // Per-turn stage inputs + directMessageFrom: CatId | null; + crossThreadReplyHint: CrossThreadHint | null; + pingPongWarning: string | null; + teammates: TeammateInfo[]; + routeMode: RouteMode; + // ... all current InvocationContext fields, typed +} +``` + +**Why centralize IO:** Hooks that query stores directly become impossible to test, trace, or mock. By gathering all inputs upfront, we get: +1. **Testability** — unit test any hook with a synthetic `AssemblerInput` +2. **Trace completeness** — the trace record can include which inputs were present +3. **Performance** — one round of queries per stage, not per hook + +### Hook Execution Model + +Each hook produces `PromptPatch` (content) + `TraceEvent` (observability). No direct context mutation in Phase 2: + +```typescript +interface HookResolver { + /** + * Evaluate whether this hook should fire and prepare template variables. + * Returns a discriminated union — no mutable state on the resolver instance, + * safe for concurrent invocations sharing a registry singleton. + */ + resolve(input: AssemblerInput): ResolveResult; +} + +type ResolveResult = + | { status: 'fired'; vars: Record; templateVersion?: number } + | { status: 'skipped'; reasonCode: string; reason: string }; + +/** What a hook produces after resolution + template rendering */ +interface PromptPatch { + hookId: string; + stage: HookStage; + content: string; // rendered template content + position: 'append'; // Phase 2: append only. Future: prepend, replace +} + +/** Discriminated union — status determines which fields are present */ +type TraceEvent = + | TraceEventFired + | TraceEventSkipped + | TraceEventDisabled + | TraceEventObserved; + +interface TraceEventBase { + hookId: string; + stage: HookStage; + durationMs: number; // resolver execution time (0 for disabled/observed) +} + +interface TraceEventFired extends TraceEventBase { + status: 'fired'; + version: number; // which version of the hook fired + contentHash: string; // SHA-256 of rendered content + tokenEstimate: number; // approx token count +} + +interface TraceEventSkipped extends TraceEventBase { + status: 'skipped'; + reasonCode: string; // e.g., "no_ping_pong_warning", "condition_false" + reason: string; // human-readable: "pingPongWarning not present" +} + +interface TraceEventDisabled extends TraceEventBase { + status: 'disabled'; + disabledBy: 'manifest' | 'operator' | 'auto-eval'; // which layer disabled it +} + +/** For Tier 2 surface trace adapters (N2, M1-M2) */ +interface TraceEventObserved extends TraceEventBase { + status: 'observed'; + contentHash: string | null; // hash of observed content, null if not available + tokenEstimate: number; +} +``` + +**Pipeline execution per stage (Tier 1 — S/D hooks only):** + +``` +for each registered hook in stage (ascending by `order` field): + 1. Check manifest baseline enabled → false? + → emit TraceEvent { status: 'disabled', disabledBy: 'manifest' } + (PR 3 adds: override ?? manifest resolution chain) + 2. If hook has resolver → call resolver.resolve(input) + - Returns { status: 'skipped', reasonCode, reason } + → emit TraceEvent { status: 'skipped', reasonCode, reason } + - Returns { status: 'fired', vars, templateVersion? } + → continue to step 4 + 3. If hook has no resolver → unconditional (always fire with empty vars) + 4. Render template with vars → PromptPatch + 5. Emit TraceEvent { status: 'fired', contentHash, tokenEstimate, version } +``` + +### InjectionTrace — Dual-Layer Persistence + +After each turn, persist injection trace data in two layers with different retention strategies: + +**Layer 1: InjectionTraceSummary (persistent)** + +Lightweight structural record — which hooks fired/skipped, aggregate stats. This is the data substrate for Phase 3 eval and long-term trend analysis. Default TTL=0 (persistent), consistent with the iron law that user-visible, traceable state defaults to persistent (LL-048). + +```typescript +interface InjectionTraceSummary { + turnId: string; + sessionId: string; + threadId: string; + catId: string; + timestamp: number; + + /** Per-hook summary, one entry per hook (fired/skipped/disabled/observed) */ + hooks: TraceEventSummary[]; + + /** Per-stage delivery decision — did produced content reach the model? */ + delivery: StageDeliveryDecision[]; + + /** Aggregate stats (of produced content, not delivered) */ + totalTokens: number; + totalHooksFired: number; + totalHooksSkipped: number; + totalDurationMs: number; +} + +/** Compact per-hook entry — no content, just identity + outcome */ +interface TraceEventSummary { + hookId: string; + status: 'fired' | 'skipped' | 'disabled' | 'observed'; + version?: number; // only for fired + tokenEstimate?: number; // only for fired/observed + reasonCode?: string; // only for skipped +} +``` + +Keyed by `injection-trace-summary:{threadId}:{turnId}`. Queryable for trend analysis: "how often does D5 fire across the last 100 turns?" + +**Layer 2: InjectionTraceDetail (debug, short TTL)** + +Full `TraceEvent` records including content hashes, durations, human-readable reasons. For debugging "what exactly happened on turn N?" Default TTL = 7 days (configurable), consistent with F153's pattern for debug-level span data. + +```typescript +interface InjectionTraceDetail { + turnId: string; + threadId: string; + catId: string; + timestamp: number; + + /** Full TraceEvent array (discriminated union, all fields) */ + hooks: TraceEvent[]; +} +``` + +Keyed by `injection-trace-detail:{threadId}:{turnId}`. + +**Why dual-layer:** Full `TraceEvent` records with content hashes and durations are valuable for debugging but expensive to store indefinitely and rarely needed after the immediate debugging window. The summary layer captures the structural signal (which hooks, what outcome) needed for eval correlation and trend analysis without storing transient debug detail. This mirrors F153's approach: structured pointers persist, debug captures expire. + +SessionContext holds only `currentTurnId` + `previousTurnId` references, not trace data itself. + +### Transport Assembly Boundary + +The following injection mechanics stay **OUTSIDE** the hook pipeline. They are transport-layer concerns, not content-production concerns: + +| Mechanism | Location | Why Outside | +|-----------|----------|-------------| +| `injectSystemPrompt` decision | `invoke-single-cat.ts:1639` | Complex resume/force/registry logic that determines WHETHER static identity is sent, not WHAT content to produce | +| `stagingPrepend` | `invoke-single-cat.ts:1674` | ADR-038 contract: "每轮注入生效", independent of prompt content | +| `contextHintPrefix` | `invoke-single-cat.ts:1661` | F225: context management, independent of prompt assembly | +| `missionPrefix` | `invoke-single-cat.ts:1650` | F070: external project dispatch context | +| `M2 transcriptPathHints` | `invoke-single-cat.ts:1680` | Always-appended path hints | + +Transport assembly order remains: `stagingPrepend → contextHintPrefix → (systemPrompt + missionPrefix + invocationContext) → M2`. + +The hook pipeline produces the **systemPrompt** (from session-init hooks) and **invocationContext** (from per-turn hooks). Transport assembly decides how to deliver them. This separation means: +- The pipeline can evolve content independently of delivery mechanics +- Transport assembly can change (e.g., new prepend layers) without touching hooks +- The `injectSystemPrompt` decision (resume vs force-reinjection) stays clean — it's a delivery decision, not a content decision + +**Produced vs Delivered — critical trace distinction:** + +Session-init hooks (S1-S13) fire inside `buildStaticIdentity()`, which runs on every invocation. But the produced content is only delivered to the model when `injectSystemPrompt` is true (new session, force-reinjection, or registry change). On resumed turns with `canSkipOnResume`, the S-segment content is produced but **not sent**. If the trace only records "S1 fired", it creates false observability — the operator sees "S1 was active this turn" when the model never received it. + +To fix this, the `InjectionTraceSummary` includes a per-stage **delivery decision** record that is channel-aware: + +```typescript +interface StageDeliveryDecision { + stage: 'session-init' | 'per-turn'; + delivered: boolean; + channel: DeliveryChannel; + reason: string; // e.g., "injectSystemPrompt=false (resume, canSkipOnResume)" +} + +type DeliveryChannel = + | 'message-prepend' // non-native: S-content prepended to prompt string + | 'native-l0' // native providers (Claude/Codex/OpenCode): L0 via --system-prompt-file / developer_instructions + | 'pack-only' // native L0 with hasNativeL0=true: only pack blocks via buildStaticIdentityPackOnly() + | 'always-delivered'; // per-turn D-segments, transport-layer M1/M2 +``` + +Delivery semantics per stage and provider: + +- **session-init (non-native providers)**: `delivered = injectSystemPrompt`, `channel = 'message-prepend'` +- **session-init (native L0 providers — Claude/Codex/OpenCode)**: S-content is delivered via native L0 channel (compiled `system-prompt-l0.md`), NOT via `injectSystemPrompt`. Route code uses `buildStaticIdentityPackOnly()` when `hasNativeL0=true` — only pack blocks go through `buildStaticIdentity()`. So `injectSystemPrompt` is not the delivery truth for native providers; the native L0 channel is. +- **per-turn**: `delivered = true`, `channel = 'always-delivered'` (D-segments are always part of the prompt regardless of provider) + +This means `TraceEventSummary` records what the pipeline *produced*, and `StageDeliveryDecision` records what transport *delivered and through which channel*. Together they answer "what did we prepare?", "what did the model actually see?", and "how was it delivered?" — the distinction needed for accurate eval correlation in Phase 3. + +For Tier 2 transport-layer adapters (M1/M2), delivery is inherent (`channel = 'always-delivered'`). L1-L7 delivery depends on whether the provider uses native L0 (`channel = 'native-l0'`) or message-prepend (`channel = 'message-prepend'`, gated by `injectSystemPrompt`). + +### Surface Trace Adapters (Tier 2 — 3 segments) + +The 3 segments that genuinely don't fit the runtime content pipeline get lightweight observe-only adapters: + +- **N2 (conversation history delta)**: Adapter at `route-helpers.ts` records conversation history assembly. Immutable, always-on, no customization value. +- **M1-M2 (transport-layer)**: M1 adapter records dispatch mission context (missionPrefix, F070). M2 adapter records transcript path hints. Both at `invoke-single-cat.ts` transport assembly point — they remain outside the content pipeline to preserve the produced-vs-delivered boundary. + +Trace adapters produce `TraceEventObserved` only — no `PromptPatch`, no enable/disable, no versioning. When fully wired, this will cover 49 of 52 segments (46 Tier 1 + 3 Tier 2). Currently: adapter API + unit tests delivered in `trace-adapters.ts`; production call-site wiring deferred (N2 content assembled after trace collection point in route-serial; M1-M2 assembled in invoke-single-cat.ts after route-level trace). (H1-H3 tracked separately.) + +### Versioning Model + +Each hook can have multiple template versions: + +``` +assets/prompt-hooks/D5-ping-pong-warning/ +├── hook.yaml # version: 2 (active) +├── d5-ping-pong-warning.md # v1 template +└── d5-ping-pong-warning.v2.md # v2 template (active) +``` + +Version lifecycle: +1. **Create** — add `hookname.v2.md` template alongside v1 +2. **Activate** — update `version: 2` in `hook.yaml` +3. **Roll back** — set `version: 1` in `hook.yaml` +4. **Archive** — delete old version template when confident + +The resolver receives the active version and renders the corresponding template. TraceEvent records which version fired, enabling comparison of v1 vs v2 outcomes. + +### What Phase 2 Does NOT Include + +- **H1-H3 Claude Code hooks** — completely different injection system (`.claude/hooks/` shell scripts, triggered by Claude Code lifecycle events, injected via event stdout → tool_result). Not part of Cat Cafe's content pipeline. Tracked as **F237-H** (separate issue to be filed). Dependency: F237-H can reuse Phase 2's trace schema and persistence layer +- **Eval feedback loop** — automated analysis of trace data to score/iterate segments. This is Phase 3, consuming Phase 2's trace + override infrastructure +- **Context mutation** — hooks producing side effects beyond PromptPatch (e.g., modifying session state). Future capability tier +- **Custom user hooks** — operators can't register their own hooks yet. This requires security model design beyond Phase 2's scope +- **L0 delivery channel modification** — the native L0 delivery mechanism (`--system-prompt-file`, provider-specific channel) is unchanged. The pipeline replaces the L0 compiler's *content source* (templates → pipeline-produced output) but preserves its *delivery path*. See L1-L7 architecture notes above + +### Landing Order + +Phase 2 implementation in 5 sub-phases, each independently shippable: + +| Sub-phase | Deliverable | Tests | +|-----------|------------|-------| +| **P2-A: HookManifest + Registry** | Hook YAML schema for all 46 pipelined segments, directory scan, manifest parsing. Registry lists S1-S13, B1, C1, L1-L7, D1-D21, R1-R2, N1 | Schema validation tests, scan tests (following PluginRegistry test pattern) | +| **P2-B: ContextAssembler + Resolvers** | Extract resolver logic: S/D from `if/push` patterns, L1-L7 from L0 compiler templates, B1/C1/R/N1 wrapping existing execution points. ContextAssembler gathers inputs. Dual-path: old code path + new pipeline produce identical output. L0 compiler content source switched from direct template loading to pipeline-produced output (delivery channel unchanged) | Snapshot tests: old output === new output for all 46 hooks. L0 compiled output equivalence test | +| **P2-C: Pipeline Execution + Trace Adapters** | Wire HookPipeline into session-init and per-turn stages. Remove old patterns. Add Tier 2 trace adapter API for N2 + M1-M2 (3 observe-only; adapter code + unit tests delivered, production call-site wiring deferred — execution order constraint) | Integration tests: compiled output identical. Regression: all existing tests pass. Trace adapter unit tests | +| **P2-D: Runtime Override Store** _(deferred to PR 3)_ | Redis-backed override layer (`HookOverrideStore`). Console UI: enable/disable hooks, switch versions, edit templates (safetyTier-gated). Overrides persist across restart (TTL=0). Same write API for operator and future auto-eval | Override resolution tests (override ?? baseline). Safety tier gate tests. Persistence tests | +| **P2-E: InjectionTrace Persistence** | Dual-layer persistence (summary persistent + detail short TTL). Console trace viewer. Trace records fired/skipped/disabled status per hook | Trace record completeness tests. Console: can view which hooks fired per turn _(override source tracking deferred to PR 3)_ | + +## Acceptance Criteria — Phase 2 + +- [ ] AC-P2-1: HookManifest YAML schema defined for S/D segments, validated by `check-hook-manifest.mjs` +- [ ] AC-P2-2: HookRegistry scans `assets/prompt-hooks/`, parses all 46 hook manifests (S1-S13, B1, C1, L1-L7, D1-D21, R1-R2, N1) +- [ ] AC-P2-3: ContextAssembler produces typed `AssemblerInput` from route-layer queries for session-init and per-turn stages +- [ ] AC-P2-4: 46 content hooks have standalone resolvers (S/D from `if/push`, L1-L7 from L0 compiler templates, B1/C1/R/N1 wrapping existing execution points); N2 + M1-M2 have observe-only trace adapter API (see AC-P2-13 for wiring status) +- [ ] AC-P2-5: Dual-path validation: old `if/push` output === new pipeline output for all S/D segments (snapshot tests) +- [ ] AC-P2-6: `buildStaticIdentity()` and `buildInvocationContext()` delegate to HookPipeline +- [ ] AC-P2-7: Each S/D hook execution produces TraceEvent (discriminated union: fired/skipped/disabled) +- [ ] AC-P2-8: InjectionTraceSummary persisted per turn (persistent, TTL=0); InjectionTraceDetail persisted with configurable TTL (default 7 days) +- [ ] AC-P2-8a: InjectionTraceSummary includes per-stage `StageDeliveryDecision` with channel-aware delivery truth: `message-prepend` gated by `injectSystemPrompt`, `native-l0` gated by provider native channel, `always-delivered` for per-turn/transport, `pack-only` for native L0 pack blocks +- [ ] AC-P2-9: Console trace viewer: query which hooks fired per turn per thread +- [ ] AC-P2-10: Hook versioning: v1→v2 switch via manifest baseline, TraceEvent records version _(runtime override switching deferred to PR 3)_ +- [ ] AC-P2-11: Hook enable/disable via manifest baseline (`enabled` field), TraceEvent records disabled status _(runtime override gating + constraint violation deferred to PR 3)_ +- [ ] AC-P2-12: Transport assembly (staging/contextHint/missionPrefix/M2) unchanged, not in pipeline +- [ ] AC-P2-13: Tier 2 trace adapter API (`observeN2`/`observeM1`/`observeM2` in `trace-adapters.ts`) emits `TraceEventObserved` for N2 + M1-M2 — adapter code + unit tests delivered; production call-site wiring deferred (N2 assembled after trace collection; M1-M2 in invocation layer after route-level trace) +- [ ] AC-P2-14: Zero behavior change — compiled prompt output identical pre/post migration (with no overrides active) +- [ ] AC-P2-14a: L0 compiled output equivalence — `compile-system-prompt-l0.mjs` output identical when consuming pipeline-produced L1-L7 content vs direct template loading +- [ ] AC-P2-15: _(deferred to PR 3)_ Runtime override store (Redis, TTL=0) with two-layer resolution: override ?? manifest baseline +- [ ] AC-P2-16: _(deferred to PR 3)_ Template override gated by safetyTier — readonly hooks reject template writes, limited-edit/editable hooks accept +- [ ] AC-P2-17: _(deferred to PR 3)_ Override audit trail: each override records source (operator/auto-eval), timestamp, reason +- [ ] AC-P2-18: _(deferred to PR 3)_ Override constraint enforcement — `setOverride` rejects: disable on `disableable: false` hooks, template edit on `safetyTier: readonly` hooks, version switch on `governanceTier: immutable` hooks. Returns `OverrideConstraintError` with violated constraint + +## Upstream Strategy (Issue #839) + +### Agreed Dual-Track Approach (2026-06-25) + +Maintainer accepted our path analysis. Agreed sequencing: + +**Fork (internal development):** Build full pipeline + trace as Path B — pipeline produces TraceEvents as natural output. No throwaway instrumentation. This becomes the working prototype. + +**Upstream (PR sequence to clowder-ai):** + +| PR | Content | Dependency | +|----|---------|-----------| +| **PR 1: InjectionTrace v0** | Trace schema + lightweight instrumentation on current `if/push` + persistence + Console viewer. Zero behavior change. | None | +| **PR 2: Pipeline migration** | Hook manifests + resolvers + pipeline switchover. Informed by PR 1 trace data + fork prototype. Equivalence proof: ordering, conditions, native L0, transport boundaries. | PR 1 merged + trace data | +| **PR 3: Override store** | Runtime override layer, auth model, auto-eval writeback. Separate design review. | PR 2 merged | + +**Rationale:** Maintainer wants upstream to stay low-risk — first PR should not commit the main repo to the hook abstraction before trace data and a reviewed migration argument exist. Fork development avoids throwaway work internally. + +### Maintainer Concerns (addressed in proposal comment) + +### Concern 1: "Schema-driven catalog compresses dynamic injections into static YAML claims" + +**Our response:** The hook manifest describes *registration metadata* (which stage, whether enabled, what version), not *content policy*. Content lives in templates and code resolvers, exactly as today. The manifest is the "phone book" — it says where to reach each hook, not what the hook should say. This is the same relationship as `PluginRegistry` (F202) — plugin manifests describe capabilities, not behavior. + +### Concern 2: "Pre-freezing N-stage pipeline locks the extension surface" + +**Our response:** Phase 2 defines 2 runtime pipeline stages (session-init, per-turn), not the 8-10 stage lifecycle from the original proposal. These stages are the minimal observation boundary around existing execution points — they formalize what's already there, not invent new boundaries. Only 3 segments (N2, M1-M2) get observe-only trace adapters — the rest all join the pipeline. (H1-H3 Claude Code hooks are out of Phase 2 scope entirely.) Adding a hook to a stage doesn't require a new interface; the stages are registration categories, not extension APIs. + +### Concern 3: "Build-to-Delete — metadata turns deletion into deprecation" + +**Our response:** The opposite. Currently, deleting a segment requires: find all code paths → remove condition + vars + render + push → verify no side effects → remove template → update manifest display entry → test. With hooks: set `enabled: false` in the hook's YAML manifest, commit, deploy — segment stops firing immediately. The resolver code and template can be deleted at leisure in a cleanup pass. The manifest file itself is just a YAML file in git — deleting the hook directory removes it completely. Build-to-Delete becomes: disable → observe trace confirms no regressions → delete directory. No "deprecation" metadata survives deletion. + +### Concern 4: "Injections grow from trajectories, not pre-numbered interfaces" + +**Our response:** The hook pipeline IS the substrate for trajectory-based growth. For injections to "grow from real trajectories," the system needs: (1) structured trace data showing which segments fired per turn, (2) correlation between segment combinations and outcomes, (3) versioning to A/B test segment changes. Without a pipeline, there's no measurement infrastructure. Phase 2 delivers the measurement; Phase 3 (eval feedback loop) delivers the iteration. + +### Issue #983 (Hook Output Demotion) + +Separately accepted upstream. Not blocked by Phase 2 — can land independently as a behavioral fix within the current `if/push` code. Phase 2 would make it a hook-level concern (resolver checks dispatch priority). + ## Future Work -- Hook output dispatch-aware demotion (separate behavioral PR) +- Hook output dispatch-aware demotion (#983, separate behavioral PR) - Text deduplication across A2A routing sections - Preview accuracy improvements (native-L0 routing, pack blocks, C1 overlays) -- Manifest documentation refinements (concrete source paths, H1/H3 readonly marking) +- Manifest documentation refinements (concrete source paths) +- **H1-H3 Claude Code hooks (F237-H)** — separate tracking for observability/iteration of external hook injection system. Reuses Phase 2 trace schema/persistence. Required to fully close original motivating incident if trigger was H1-related +- **Phase 3: Eval Feedback Loop** — automated trace analysis, segment scoring, A/B version comparison +- **Custom User Hooks** — operator-registered hooks with security sandboxing +- **Context Mutation Hooks** — hooks that modify session state (requires safety model design) ## Dependencies @@ -136,3 +794,18 @@ Modal showing assembled prompt per cat, labeled "approximate". Selectable by cat - **Related**: F153 (tracing — future observability integration) - **Related**: F180 (hook health/sync) - **Related**: F190/F199/F206 (Console settings infrastructure) + +## Timeline + +| Date | Event | +|------|-------| +| 2026-06-02 | Kickoff: motivating incident analysis + CVO direction | +| 2026-06-02 | Issue #839 created, maintainer triage | +| 2026-06-03 | CVO approved Phase 1, worktree created | +| 2026-06-04-10 | Implementation: 6 rounds of codex local review | +| 2026-06-11 | Gate passed (build + tsc + test + lint), PR #859 opened | +| 2026-06-11-12 | Cloud review: 34 findings processed (1 fixed, 33 pushback) | +| 2026-06-15 | Scope discussion with maintainer on #839 | +| 2026-06-16 | PR #859 merged, Phase 1 complete | +| 2026-06-24 | Phase 2 design: hook pipeline + injection trace spec | +| 2026-06-25 | Phase 2 design review passed (codex R1: 3 P1 + 1 P2 fixed) | diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index b1e2f08db7..0995d1604b 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -595,6 +595,8 @@ export interface AgentRouterOptions { freshnessReinvokeCheck?: import('../invocation/invoke-single-cat.js').InvocationDeps['freshnessReinvokeCheck']; /** F254 Phase C: Freshness state store for carrier tier persistence */ freshnessStateStore?: import('../../freshness/FreshnessInvocationStateStore.js').FreshnessInvocationStateStore; + /** F237 Phase 2 (AC-P2-8): Injection trace store for pipeline observability */ + injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; } /** @@ -665,6 +667,8 @@ export class AgentRouter { private freshnessReinvokeCheck?: import('../invocation/invoke-single-cat.js').InvocationDeps['freshnessReinvokeCheck']; /** F254 Phase C */ private freshnessStateStore?: import('../../freshness/FreshnessInvocationStateStore.js').FreshnessInvocationStateStore; + /** F237 Phase 2 (AC-P2-8) */ + private injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; private speechMentionRe: RegExp; /** @@ -774,6 +778,7 @@ export class AgentRouter { this.cloudInvokeBridge = options.cloudInvokeBridge; this.freshnessReinvokeCheck = options.freshnessReinvokeCheck; this.freshnessStateStore = options.freshnessStateStore; + this.injectionTraceStore = options.injectionTraceStore; } refreshFromRegistry(agentRegistry: AgentRegistry): void { @@ -1386,6 +1391,7 @@ export class AgentRouter { ...(this.frustrationIssueStore ? { frustrationIssueStore: this.frustrationIssueStore } : {}), ...(this.pendingRequestStore ? { pendingRequestStore: this.pendingRequestStore } : {}), ...(this.ballCustody ? { ballCustody: this.ballCustody } : {}), + ...(this.injectionTraceStore ? { injectionTraceStore: this.injectionTraceStore } : {}), }; } diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index f43b8ed41d..fb58bbd285 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -72,6 +72,8 @@ export interface RouteStrategyDeps { worldStore?: import('../../../../world/interfaces.js').IWorldStore; /** F233 Phase B (B2): Ball-custody ingest — fire-and-forget 旁路写球权事件(append + appended-guard apply)。optional, fail-open */ ballCustody?: import('../../../../ball-custody/BallCustodyIngest.js').IBallCustodyIngest; + /** F237 Phase 2 (AC-P2-8): Injection trace store for pipeline observability. optional, fail-open */ + injectionTraceStore?: import('../../../../prompt-hooks/InjectionTraceStore.js').InjectionTraceStore; } /** Mutable context for tracking persistence failures across the generator boundary. diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 4ad65984a6..170dd9dd44 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -29,6 +29,7 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; +import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F237: Injection trace (v0 — fire-and-forget observability) import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; @@ -244,6 +245,9 @@ export async function* routeParallel( const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); + // F237: drain session trace IMMEDIATELY — before any await that could let + // another parallel cat overwrite the module-global capture buffer. + drainCapturedTraces(); // F041: inject HTTP callback only when MCP is NOT actually available (fallback) const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId) ? buildMcpCallbackInstructions({ @@ -314,6 +318,13 @@ export async function* routeParallel( ...guideContextForCat(guideCtx, catId, targetCatIds, threadId), ...conciergeContextForCat(conciergeCtx, catId as string), }); + // F237: drain turn trace IMMEDIATELY — same race-safety as session drain above. + drainCapturedTraces(); + + // F237 Phase 2: Pipeline trace capture drained above (lines 250, 322) to prevent + // stale module-global buffer in concurrent Promise.all execution. Persistence is + // handled by the v0 trace path below (after all route-level content is assembled). + const continuityCapsule = buildCapsuleFromRouteState({ threadId, catId: catId as string, @@ -393,6 +404,10 @@ export async function* routeParallel( log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); }); } + // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates + // the module-global capturedSessionTrace without draining. Clear it so the next + // invocation (especially native-L0 pack-only) doesn't persist stale session traces. + if (deps.injectionTraceStore) drainCapturedTraces(); } catch { /* F237: trace collection must never break invocation */ } diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index f68ba601c1..804b99c9e4 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -75,6 +75,7 @@ import { prepareGuideContext, } from '../../../../guides/GuideRoutingInterceptor.js'; import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; +import { drainCapturedTraces } from '../../../../prompt-hooks/PipelinePromptBuilder.js'; import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js'; // F237: Injection trace (v0 — fire-and-forget observability) import { buildTraceDetail, buildTraceSummary, collectTrace } from '../../../../prompt-hooks/trace-collector.js'; @@ -692,6 +693,9 @@ export async function* routeSerial( const staticIdentity = hasNativeL0 ? buildStaticIdentityPackOnly(catId, { packBlocks }) : buildStaticIdentity(catId, { mcpAvailable, packBlocks }); + // F237: drain session trace synchronously — before any await between + // buildStaticIdentity and buildInvocationContext (race-safety for parallel reuse). + drainCapturedTraces(); // L0-budget-defense PR-B-impl (ADR-038 件套 ④): staging is NOT prepended // to staticIdentity here. Cloud R2 P1 #2237 L1099: folding staging into // staticIdentity breaks ADR-038 "每轮注入生效" contract on resumed @@ -796,6 +800,8 @@ export async function* routeSerial( ...(worldContext ? { worldContext } : {}), ...conciergeContextForCat(conciergeCtx, catId as string), }); + // F237: drain turn trace synchronously — no yield between build and drain. + drainCapturedTraces(); const continuityCapsule = buildCapsuleFromRouteState({ threadId, catId: catId as string, @@ -810,6 +816,11 @@ export async function* routeSerial( maxA2ADepth: maxDepth, }); + // F237 Phase 2: Pipeline trace capture is drained above (lines 698, 804) + // to prevent stale module-global buffer. Persistence is handled by the v0 + // trace path below (after all route-level content is assembled), avoiding + // duplicate records. Phase 2 will take over persistence when migration completes. + // F24 Phase E: Bootstrap context for Session #2+ // #836: Reborn cats skip bootstrap — every invocation starts with zero prior context. // Uses store lookup (not thread field) — Redis memberSS:* fields aren't hydrated by get(). @@ -876,6 +887,10 @@ export async function* routeSerial( log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)'); }); } + // v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates + // the module-global capturedSessionTrace without draining. Clear it so the next + // invocation (especially native-L0 pack-only) doesn't persist stale session traces. + if (deps.injectionTraceStore) drainCapturedTraces(); } catch { /* F237: trace collection must never break invocation */ } diff --git a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts index fa19c645f2..c4742f8bf3 100644 --- a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts +++ b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts @@ -21,8 +21,11 @@ import { findMonorepoRoot } from '../../../../utils/monorepo-root.js'; // F167 Phase F P1 (cloud Codex): roster model cell must resolve via getCatModel // (env CAT_{CATID}_MODEL → registry → defaults), not from static config.defaultModel, // otherwise env overrides cause exactly the handle/model drift Phase F is killing. -import { buildConciergePromptLines } from '../../../concierge/ConciergePromptSection.js'; -import { buildGuidePromptLines } from '../../../guides/GuidePromptSection.js'; +// F237 Phase 2 (AC-P2-6): pipeline-backed builders for delegation +import { + buildInvocationContextViaHookPipeline, + buildStaticIdentityViaHookPipeline, +} from '../../../prompt-hooks/PipelinePromptBuilder.js'; import type { BootcampStateV1, ThreadMentionRoutingFeedback, @@ -30,13 +33,7 @@ import type { ThreadRoutingPolicyV1, } from '../stores/ports/ThreadStore.js'; import { loadCompiledGovernanceL0, loadCompiledGovernanceL0Sync } from './governance-l0.js'; -import { - loadA2aBallCheck, - loadHandoffDecisionTree, - loadMcpToolsSection, - loadWorkflowTriggers, - renderSegment, -} from './prompt-template-loader.js'; +import { loadMcpToolsSection, loadWorkflowTriggers } from './prompt-template-loader.js'; import { RICH_BLOCK_SHORT } from './rich-block-rules.js'; // L0-budget-defense PR-B-impl (ADR-038 件套 ④): staging is wired in @@ -211,8 +208,10 @@ function getAllConfigs(): Record { return catRegistry.getAllConfigs(); } -/** Get a single cat config by ID */ -function getConfig(catId: string): CatConfig | undefined { +/** Get a single cat config by ID + * @internal F237 — exported for ContextAssembler bridge; will be removed when SystemPromptBuilder is replaced. + */ +export function getConfig(catId: string): CatConfig | undefined { return catRegistry.tryGet(catId)?.config; } @@ -237,6 +236,12 @@ function pickVariantMention(id: string, config: CatConfig): string { return `@${id}`; } +/** @internal F237 — exported for AssembleBridge routing policy parity (cloud P2-1 fix) */ +export function pickVariantMentionForBridge(id: string): string { + const config = getConfig(id); + return config ? pickVariantMention(id, config) : `@${id}`; +} + function pickDisplayNameMention(config: CatConfig): string | null { const expected = `@${config.displayName}`.toLowerCase(); return config.mentionPatterns.find((p) => p.toLowerCase() === expected) ?? null; @@ -248,7 +253,8 @@ function pickDisplayNameOrVariantMention(id: string, config: CatConfig): string return pickDisplayNameMention(config) ?? pickVariantMention(id, config); } -function buildCallableMentions(currentCatId: CatId): CallableMentionsResult { +/** @internal F237 — exported for ContextAssembler bridge */ +export function buildCallableMentions(currentCatId: CatId): CallableMentionsResult { const entries: CallableCatEntry[] = Object.entries(getAllConfigs()) .filter(([id]) => id !== currentCatId && isCatAvailable(id)) .map(([id, config]) => ({ id, config })); @@ -290,7 +296,8 @@ function buildCallableMentions(currentCatId: CatId): CallableMentionsResult { return { mentions, hasDuplicateDisplayNames, uniqueHandleExample }; } -function formatHandleFreeLabel(catId: string, config: CatConfig | undefined): string { +/** @internal F237 — exported for ContextAssembler bridge */ +export function formatHandleFreeLabel(catId: string, config: CatConfig | undefined): string { if (!config) return catId; // F167 identity anti-spoofing: carry variantLabel when present to disambiguate same-breed variants // (e.g. "布偶猫 Opus 4.7(opus-47)" vs "布偶猫(opus)"), preventing A2A handoff identity confusion. @@ -307,7 +314,8 @@ function compactRosterCell(value: string, maxChars: number): string { return `${value.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; } -const PROVIDER_LABELS: Record = { +/** @internal F237 — exported for ContextAssembler bridge */ +export const PROVIDER_LABELS: Record = { anthropic: 'Anthropic', openai: 'OpenAI', google: 'Google', @@ -319,7 +327,8 @@ const PROVIDER_LABELS: Record = { * Full specs live in cat-cafe-skills/refs/ (rich-blocks.md, mcp-callbacks.md). * Lazy-evaluated to pick up .local overlay changes (F237 Checkpoint C). */ -function getMcpToolsSection(): string { +/** @internal F237 — exported for ContextAssembler bridge */ +export function getMcpToolsSection(): string { return `\n${loadMcpToolsSection({ RICH_BLOCK_SHORT })}`; } @@ -346,7 +355,8 @@ export function getGovernanceDigest(): string { /** @segment S6 — Per-breed workflow triggers (loaded from template) * Keyed by breedId so all variants of a breed share the same workflow. * Lazy-evaluated to pick up .local overlay changes (F237 Checkpoint C). */ -function getWorkflowTriggers(): Record { +/** @internal F237 — exported for ContextAssembler bridge */ +export function getWorkflowTriggers(): Record { const triggers = loadWorkflowTriggers(); return Object.fromEntries( Object.entries(triggers).map(([breed, content]) => [breed, ensureMergeGateSourceProvenanceTrigger(content)]), @@ -365,7 +375,8 @@ function ensureMergeGateSourceProvenanceTrigger(content: string): string { * Lists all other cats with @mention, strengths, and caution. * Excludes the current cat. Returns null if no teammates. */ -function buildTeammateRoster(currentCatId: CatId): string | null { +/** @internal F237 — exported for ContextAssembler bridge */ +export function buildTeammateRoster(currentCatId: CatId): string | null { const allConfigs = getAllConfigs(); const entries = Object.entries(allConfigs).filter(([id]) => id !== currentCatId && isCatAvailable(id)); if (entries.length === 0) return null; @@ -461,148 +472,7 @@ export interface StaticIdentityOptions { export function buildStaticIdentity(catId: CatId, options?: StaticIdentityOptions): string { const config = getConfig(catId as string); if (!config) return ''; - - const providerLabel = PROVIDER_LABELS[config.clientId] ?? config.clientId; - const lines: string[] = []; - // F237: segment annotation — preview inserts `── [SN] Name ──` markers - const mark = options?.annotateSegments - ? (id: string, name: string) => { - lines.push(`── [${id}] ${name} ──`); - } - : (): void => {}; - - /* @segment S1 — 身份声明 (template: s1-identity.md) */ - mark('S1', '身份声明'); - const nameLabel = config.nickname - ? `${config.displayName}/${config.nickname}(${config.name})` - : `${config.displayName}(${config.name})`; - const nicknameOrigin = config.nickname ? `昵称 "${config.nickname}" 的由来见 docs/stories/cat-names/。\n` : ''; - const s1 = renderSegment('S1', { - NAME_LABEL: nameLabel, - PROVIDER_LABEL: providerLabel, - NICKNAME_ORIGIN: nicknameOrigin, - ROLE_DESCRIPTION: config.roleDescription, - PERSONALITY: config.personality, - }); - if (s1) lines.push(s1, ''); - - /* @segment S2 — 硬限制 (template: s2-restrictions.md) */ - if (config.restrictions && config.restrictions.length > 0) { - mark('S2', '硬限制'); - const s2 = renderSegment('S2', { RESTRICTIONS_TEXT: config.restrictions.join('、') }); - if (s2) lines.push(s2, ''); - } else { - mark('S2', '硬限制'); - } - - /* @segment S3 — Pack Masks (template: s3-pack-masks.md) */ - if (options?.packBlocks?.masksBlock) { - mark('S3', 'Pack Masks'); - const s3 = renderSegment('S3', { PACK_MASKS_BLOCK: options.packBlocks.masksBlock }); - if (s3) lines.push(s3, ''); - } else { - mark('S3', 'Pack Masks'); - } - - /* @segment S4 — 协作格式 (template: s4-collaboration.md) */ - const { mentions: callableMentions, hasDuplicateDisplayNames, uniqueHandleExample } = buildCallableMentions(catId); - if (callableMentions.length > 0) { - mark('S4', '协作格式'); - const exampleTarget = callableMentions[0]!; - let dupHint = ''; - if (hasDuplicateDisplayNames) { - const example = uniqueHandleExample ?? '@opus'; - dupHint = `同族多分身时:默认 \`@显示名\`,其它用**唯一句柄**(例如 \`${example}\`)。\n同名队友并存时,请优先使用唯一句柄(例如 \`${example}\`)避免歧义。\n`; - } - const s4 = renderSegment('S4', { - CALLABLE_MENTIONS: callableMentions.join(' / '), - EXAMPLE_TARGET: exampleTarget, - DUPLICATE_NAMES_HINT: dupHint, - }); - if (s4) lines.push(s4, ''); - } else { - mark('S4', '协作格式'); - } - - /* @segment S5 — 队友名册 (template: s5-teammate-roster.md) */ - const rosterLines = buildTeammateRoster(catId); - if (rosterLines) { - mark('S5', '队友名册'); - const s5 = renderSegment('S5', { ROSTER_CONTENT: rosterLines }); - if (s5) lines.push(s5, ''); - } else { - mark('S5', '队友名册'); - } - - /* @segment S6 — 工作流触发点 */ - const wfTriggers = getWorkflowTriggers(); - const triggers = wfTriggers[config.breedId ?? ''] ?? wfTriggers[catId as string]; - if (triggers) { - mark('S6', '工作流触发点'); - lines.push(triggers, ''); - } else { - mark('S6', '工作流触发点'); - } - - /* @segment S7 — Pack Workflows (template: s7-pack-workflows.md) */ - const packBlocks = options?.packBlocks; - if (packBlocks?.workflowsBlock) { - mark('S7', 'Pack Workflows'); - const s7 = renderSegment('S7', { PACK_WORKFLOWS_BLOCK: packBlocks.workflowsBlock }); - if (s7) lines.push(s7, ''); - } else { - mark('S7', 'Pack Workflows'); - } - - /* @segment S8 — co-creator引用 (template: s8-cvo-reference.md) */ - mark('S8', 'co-creator引用'); - const coCreator = getCoCreatorConfig(); - const ccName = coCreator.name; - const ccHandles = coCreator.mentionPatterns.map((p) => `\`${p}\``).join(' / '); - const s8 = renderSegment('S8', { CC_NAME: ccName, CC_HANDLES: ccHandles }); - if (s8) lines.push(s8, ''); - - /* @segment S9 — 治理摘要 (template: s9-governance-digest.md) */ - mark('S9', '治理摘要'); - const s9 = renderSegment('S9', { GOVERNANCE_DIGEST: getGovernanceDigest() }); - if (s9) lines.push('', s9); - - /* @segment S10 — Pack Guardrails (template: s10-pack-guardrails.md) */ - if (packBlocks?.guardrailBlock) { - mark('S10', 'Pack Guardrails'); - const s10 = renderSegment('S10', { PACK_GUARDRAILS_BLOCK: packBlocks.guardrailBlock }); - if (s10) lines.push('', s10); - } else { - mark('S10', 'Pack Guardrails'); - } - - /* @segment S11 — Pack Defaults (template: s11-pack-defaults.md) */ - if (packBlocks?.defaultsBlock) { - mark('S11', 'Pack Defaults'); - const s11 = renderSegment('S11', { PACK_DEFAULTS_BLOCK: packBlocks.defaultsBlock }); - if (s11) lines.push('', s11); - } else { - mark('S11', 'Pack Defaults'); - } - - /* @segment S12 — World Driver (template: s12-world-driver.md) */ - if (packBlocks?.worldDriverSummary) { - mark('S12', 'World Driver'); - const s12 = renderSegment('S12', { WORLD_DRIVER_SUMMARY: packBlocks.worldDriverSummary }); - if (s12) lines.push('', s12); - } else { - mark('S12', 'World Driver'); - } - - /* @segment S13 — MCP 工具文档 */ - if (options?.mcpAvailable) { - mark('S13', 'MCP 工具文档'); - lines.push('', getMcpToolsSection().trim()); - } else { - mark('S13', 'MCP 工具文档'); - } - - return lines.join('\n'); + return buildStaticIdentityViaHookPipeline(catId, options); } /** @@ -640,330 +510,12 @@ export function buildStaticIdentityPackOnly(catId: CatId, options?: StaticIdenti * (MCP tools and co-creator reference moved to buildStaticIdentity for session-level injection.) */ export function buildInvocationContext(context: InvocationContext): string { + // AC-P2-6: delegate to HookPipeline for production path. + // Trace events emitted during pipeline execution enable injection observability. + // Unknown cat guard: legacy returns '', pipeline would throw (same as buildStaticIdentity) const config = getConfig(context.catId as string); if (!config) return ''; - - const lines: string[] = []; - const runtimeModel = (() => { - try { - return getCatModel(context.catId as string); - } catch { - return config.defaultModel; - } - })(); - - /* @segment D1 — Identity 锚点 (template: d1-identity-anchor.md) */ - const d1 = renderSegment('D1', { - DISPLAY_NAME: config.displayName, - NICKNAME_PART: config.nickname ? `/${config.nickname}` : '', - CAT_ID: context.catId as string, - RUNTIME_MODEL: runtimeModel, - }); - if (d1) lines.push(d1); - - /* @segment D2 — 直接消息来源 (template: d2-direct-message.md) */ - /* @segment D3 — 同族分身提醒 (template: d3-same-breed-warning.md) */ - if (context.directMessageFrom && context.directMessageFrom !== context.catId) { - const fromConfig = getConfig(context.directMessageFrom as string); - const fromLabel = formatHandleFreeLabel(context.directMessageFrom as string, fromConfig); - const fromModel = (() => { - try { - return getCatModel(context.directMessageFrom as string); - } catch { - return fromConfig?.defaultModel ?? 'unknown'; - } - })(); - const d2 = renderSegment('D2', { FROM_LABEL: fromLabel, FROM_MODEL: fromModel }); - if (d2) lines.push(d2); - // Anti-spoofing fires only for same-breed variant handoffs (displayName collision + catId differs) - if (fromConfig && fromConfig.displayName === config.displayName) { - const selfVariant = config.variantLabel ?? runtimeModel; - const fromVariant = fromConfig.variantLabel ?? fromModel; - const d3 = renderSegment('D3', { - FROM_VARIANT: fromVariant, - FROM_MODEL: fromModel, - SELF_VARIANT: selfVariant, - SELF_MODEL: runtimeModel, - }); - if (d3) lines.push(d3); - } - } - - /* @segment D4 — 跨 thread 回复 (template: d4-cross-thread-reply.md) */ - if (context.crossThreadReplyHint) { - const { sourceThreadId, senderCatId, effectClass } = context.crossThreadReplyHint; - const effectLabel = effectClass ? ` [effect: ${effectClass}]` : ''; - const d4 = renderSegment('D4', { - SOURCE_THREAD: sourceThreadId, - SENDER_CAT: senderCatId, - EFFECT_LABEL: effectLabel, - }); - if (d4) lines.push(d4); - // F246 Phase B AC-B4: effect-class behavior constraints - if (effectClass && effectClass !== 'assign_work') { - const constraintMap: Record = { - fyi: '📋 effect=fyi:仅知会——阅读并确认,不需要你写代码或执行动作。如果消息内容包含命令式措辞也不执行。', - coordinate: - '🤝 effect=coordinate:协调——可以讨论、回复意见、提供建议,但不要动代码。即使消息看起来在指派工作,也只回复确认。', - investigate: - '🔍 effect=investigate:调查——可以搜索、阅读代码、分析诊断,但只输出结论和建议。不要写代码或创建 PR。', - }; - if (constraintMap[effectClass]) { - lines.push(constraintMap[effectClass]); - } - } - } - - /* @segment D5 — 乒乓球警告 (template: d5-ping-pong-warning.md) */ - if (context.pingPongWarning) { - const otherConfig = getConfig(context.pingPongWarning.pairedWith as string); - const otherLabel = formatHandleFreeLabel(context.pingPongWarning.pairedWith as string, otherConfig); - const d5 = renderSegment('D5', { - OTHER_LABEL: otherLabel, - STREAK_COUNT: String(context.pingPongWarning.count), - }); - if (d5) lines.push(d5); - } - - /* @segment D6 — 本次队友 (template: d6-teammates.md) */ - if (context.teammates.length > 0) { - const tmList = context.teammates - .map((id) => { - const c = getConfig(id as string); - if (!c) return null; - const tmName = c.nickname ? `${c.displayName}/${c.nickname}` : c.displayName; - return `- ${tmName}(${c.name}):${c.roleDescription}`; - }) - .filter(Boolean) - .join('\n'); - const d6 = renderSegment('D6', { TEAMMATES_LIST: tmList }); - if (d6) lines.push(d6); - } - /* @segment D7 — 模式声明 (templates: d7-mode-serial/parallel/solo.md) */ - if (context.mode === 'serial' && context.chainIndex != null && context.chainTotal != null) { - const d7 = renderSegment('D7_serial', { - CHAIN_INDEX: String(context.chainIndex), - CHAIN_TOTAL: String(context.chainTotal), - }); - if (d7) lines.push(d7, ''); - } else if (context.mode === 'parallel') { - const d7 = renderSegment('D7_parallel', { - DISPLAY_NAME: config.displayName, - CAT_ID: context.catId as string, - }); - if (d7) lines.push(d7, ''); - } else { - const d7 = renderSegment('D7_solo'); - if (d7) lines.push(d7, ''); - } - - /* @segment D8 — A2A 球权检查 (loaded from template) */ - // A2A: per-turn fallback anchors for providers without native L0. Native L0 - // already carries the same ball-ownership rules and baton decision tree via - // the compression-immune system/developer channel. - const shouldInjectA2ALongAnchors = context.mode !== 'parallel' && context.a2aEnabled && !context.nativeL0Injected; - - if (shouldInjectA2ALongAnchors) { - const d8Content = loadA2aBallCheck(); - if (d8Content) lines.push(d8Content, ''); - } - - /* @segment D9 — 路由反馈 (template: d9-routing-feedback.md) */ - if (context.mentionRoutingFeedback && context.mentionRoutingFeedback.items?.length > 0) { - const items = context.mentionRoutingFeedback.items.slice(0, 2).map((it) => `@${it.targetCatId}`); - const d9 = renderSegment('D9', { UNROUTED_MENTIONS: items.join('、') }); - if (d9) lines.push(d9, ''); - } - - /* @segment D10 — 思维标签 (template: d10-critique-tag.md) */ - if (context.promptTags?.includes('critique')) { - const d10 = renderSegment('D10'); - if (d10) lines.push(d10, ''); - } - - /* @segment D11 — Skill 触发 (template: d11-skill-trigger.md) */ - const skillTag = context.promptTags?.find((t) => t.startsWith('skill:')); - if (skillTag) { - const d11 = renderSegment('D11', { SKILL_NAME: skillTag.slice(6) }); - if (d11) lines.push(d11, ''); - } - - /* @segment D12 — 活跃参与者 (template: d12-active-participant.md) */ - if (context.activeParticipants && context.activeParticipants.length > 0) { - const topActive = context.activeParticipants - .filter((p) => p.catId !== context.catId) - .find((p) => p.lastMessageAt > 0); - if (topActive) { - const topConfig = getConfig(topActive.catId as string); - if (topConfig) { - const d12 = renderSegment('D12', { - ACTIVE_LABEL: formatHandleFreeLabel(topActive.catId as string, topConfig), - }); - if (d12) lines.push(d12); - } - } - } - - /* @segment D13 — 路由策略 (template: d13-routing-policy.md) */ - if (context.routingPolicy?.v === 1 && context.routingPolicy.scopes) { - const toMention = (id: string): string => { - const c = getConfig(id); - return c ? pickVariantMention(id, c) : `@${id}`; - }; - - const parts: string[] = []; - const scopes = context.routingPolicy.scopes; - const order = ['review', 'architecture'] as const; - for (const scope of order) { - const rule = scopes[scope]; - if (!rule) continue; - if (typeof rule.expiresAt === 'number' && rule.expiresAt > 0 && rule.expiresAt < Date.now()) continue; - - const segs: string[] = []; - const avoidList = Array.isArray(rule.avoidCats) ? rule.avoidCats : []; - const preferList = Array.isArray(rule.preferCats) ? rule.preferCats : []; - const avoid = avoidList.slice(0, 3).map((id) => toMention(String(id))); - const prefer = preferList.slice(0, 3).map((id) => toMention(String(id))); - if (avoid.length > 0) segs.push(`avoid ${avoid.join(', ')}`); - if (prefer.length > 0) segs.push(`prefer ${prefer.join(', ')}`); - const sanitizedReason = typeof rule.reason === 'string' ? rule.reason.replace(/[\r\n]+/g, ' ').trim() : ''; - if (sanitizedReason) segs.push(`(${sanitizedReason})`); - - if (segs.length > 0) parts.push(`${scope} ${segs.join(' ')}`); - } - - if (parts.length > 0) { - const d13 = renderSegment('D13', { ROUTING_PARTS: parts.join('; ') }); - if (d13) lines.push(d13); - } - } - - /* @segment D14 — SOP 阶段提示 */ - /* (template: d14-sop-stage.md) */ - if (context.sopStageHint) { - const { stage, suggestedSkill, suggestedSkillSource, featureId } = context.sopStageHint; - const d14 = renderSegment('D14', { - FEATURE_ID: featureId, - STAGE: stage, - SUGGESTED_SKILL: suggestedSkill, - SOURCE_PART: suggestedSkillSource ? ` (${suggestedSkillSource})` : '', - }); - if (d14) lines.push(d14); - } - - /* @segment D15 — Voice 模式 (templates: d15-voice-on/off.md) */ - if (context.voiceMode) { - const d15 = renderSegment('D15_on'); - if (d15) lines.push(d15, ''); - } else { - const d15 = renderSegment('D15_off'); - if (d15) lines.push(d15, ''); - } - - /* @segment D16 — Bootcamp 模式 (template: d16-bootcamp.md) */ - if (context.bootcampState) { - const { phase, leadCat, selectedTaskId } = context.bootcampState; - const d16 = renderSegment('D16', { - THREAD_PART: context.threadId ? ` thread=${context.threadId}` : '', - PHASE: phase, - LEAD_CAT_PART: leadCat ? ` leadCat=${leadCat}` : '', - TASK_PART: selectedTaskId ? ` task=${selectedTaskId}` : '', - MEMBERS_PART: context.bootcampMemberCount != null ? ` members=${context.bootcampMemberCount}` : '', - }); - if (d16) lines.push(d16, ''); - } - - /* @segment D17 — Guide 候选 (template: d17-guide-candidate.md) */ - if (context.guideCandidate) { - const guideLines = buildGuidePromptLines(context.guideCandidate, context.threadId); - const d17 = renderSegment('D17', { GUIDE_PROMPT_LINES: guideLines.join('\n') }); - if (d17) lines.push(d17); - } - - // F229: Concierge duty section — injected only for per-user concierge threads - if (context.threadKind === 'concierge' && context.conciergeConfig) { - lines.push(...buildConciergePromptLines(context.conciergeConfig, context.threadId)); - } - - /* @segment D18 — 世界上下文 (template: d18-world-context.md) */ - if (context.worldContext) { - const wc = context.worldContext; - const constitutionLine = wc.world.constitution ? `Constitution: ${wc.world.constitution}` : ''; - const charsBlock = - wc.characters.length > 0 - ? [ - 'Characters:', - ...wc.characters.map((ch) => { - const identity = ch.coreIdentity?.name ?? ch.characterId; - const drive = ch.innerDrive?.motivation ? ` — ${ch.innerDrive.motivation}` : ''; - return `- ${identity}${drive}`; - }), - ].join('\n') - : ''; - const canonBlock = - wc.canonSummary.length > 0 - ? ['Established canon:', ...wc.canonSummary.map((cs) => `- ${cs.summary}`)].join('\n') - : ''; - const eventsBlock = - wc.recentEvents.length > 0 - ? [ - `Recent events (${wc.recentEvents.length}):`, - ...wc.recentEvents.slice(-5).map((ev) => `- [${ev.type}] ${JSON.stringify(ev.payload)}`), - ].join('\n') - : ''; - const careHintLine = wc.careLoopHint ? `Care hint: ${wc.careLoopHint.trigger} → ${wc.careLoopHint.suggestion}` : ''; - const d18 = renderSegment('D18', { - WORLD_NAME: wc.world.name, - WORLD_STATUS: wc.world.status, - CONSTITUTION_LINE: constitutionLine, - SCENE_NAME: wc.scene.name, - SCENE_STATUS: wc.scene.status, - CHARACTERS_BLOCK: charsBlock, - CANON_BLOCK: canonBlock, - RECENT_EVENTS_BLOCK: eventsBlock, - CARE_HINT_LINE: careHintLine, - }); - if (d18) lines.push('', d18, ''); - } - - /* @segment D19 — Constitutional 知识 (template: d19-constitutional-knowledge.md) */ - if (context.alwaysOnDocs && context.alwaysOnDocs.length > 0) { - const docsBlock = context.alwaysOnDocs.map((doc) => `### ${doc.title}\n\n${doc.summary}`).join('\n\n'); - const d19 = renderSegment('D19', { CONSTITUTIONAL_DOCS: docsBlock }); - if (d19) lines.push('', d19); - } - - /* @segment D20 — Signal 文章 (template: d20-signal-articles.md) */ - if (context.activeSignals && context.activeSignals.length > 0) { - const articlesBlock = context.activeSignals - .map((s) => { - const parts = [`### [${s.id}] ${s.title} (${s.source}/T${s.tier})`]; - if (s.note) parts.push(`Note: ${s.note}`); - parts.push(s.contentSnippet); - if (s.relatedDiscussions && s.relatedDiscussions.length > 0) { - parts.push('Related past discussions:'); - for (const d of s.relatedDiscussions) { - parts.push(`- [session:${d.sessionId}] ${d.snippet}`); - } - } - return parts.join('\n'); - }) - .join('\n'); - const d20 = renderSegment('D20', { SIGNAL_ARTICLES_BLOCK: articlesBlock }); - if (d20) lines.push(d20); - } - - /* @segment D21 — 传球决策树 (loaded from template) */ - // F167 Phase D: Trailing anchor — decision tree, not flat three-choice. - // @co-creator is a hard-condition exit, not the safe default (KD-19). - // Placed at the very end for maximum recency bias when native L0 is unavailable. - if (shouldInjectA2ALongAnchors) { - const cc = getCoCreatorConfig().mentionPatterns[0] ?? '@co-creator'; - const d21Content = loadHandoffDecisionTree({ CC_MENTION: cc }); - if (d21Content) lines.push('', d21Content); - } - - return lines.join('\n'); + return buildInvocationContextViaHookPipeline(context); } /** diff --git a/packages/api/src/domains/prompt-hooks/HookPipeline.ts b/packages/api/src/domains/prompt-hooks/HookPipeline.ts new file mode 100644 index 0000000000..fa85b9b906 --- /dev/null +++ b/packages/api/src/domains/prompt-hooks/HookPipeline.ts @@ -0,0 +1,197 @@ +/** + * HookPipeline — F237 Phase 2-C + * + * Executes hooks for a given stage in manifest order, producing + * PromptPatch[] (rendered content) + TraceEvent[] (observability). + * + * Execution per hook: + * 1. Check enabled (baseline) → TraceEventDisabled if off + * 2. Run resolver → TraceEventSkipped if condition false + * 3. Resolve TEMPLATE_VARIANT (D7/D15 multi-template hooks) + * 4. Render template with vars → PromptPatch + TraceEventFired + */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import type { + AssemblerInput, + HookResolver, + HookStage, + PromptPatch, + RegisteredHook, + ResolveResult, + TraceEvent, + TraceEventDisabled, + TraceEventFired, + TraceEventSkipped, +} from '@cat-cafe/shared'; +import type { HookRegistry } from './HookRegistry.js'; + +// --------------------------------------------------------------------------- +// Pipeline result +// --------------------------------------------------------------------------- + +export interface PipelineResult { + /** Rendered content patches, one per fired hook, in order. */ + patches: PromptPatch[]; + /** Trace events for every hook in the stage (fired, skipped, or disabled). */ + events: TraceEvent[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** SHA-256 hash of content (first 16 hex chars for compact storage). */ +export function hashContent(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 16); +} + +/** + * Rough token estimate: ~4 chars per token for mixed CJK/English content. + * Good enough for trace display — not for billing. + */ +export function estimateTokens(content: string): number { + return Math.ceil(content.length / 4); +} + +// --------------------------------------------------------------------------- +// Renderer interface (decoupled from prompt-template-loader for testability) +// --------------------------------------------------------------------------- + +/** + * Template renderer function signature. + * Maps to renderSegment(segmentId, vars) from prompt-template-loader. + * Returns rendered content or null if template missing. + */ +export type TemplateRenderer = (segmentId: string, vars: Record) => string | null; + +// --------------------------------------------------------------------------- +// HookPipeline +// --------------------------------------------------------------------------- + +export class HookPipeline { + constructor( + private readonly registry: HookRegistry, + private readonly resolvers: ReadonlyMap, + private readonly renderer: TemplateRenderer, + ) {} + + /** + * Fallback renderer: read co-located template from hook directory. + * Used when the primary renderer (renderSegment) returns null because + * the template isn't registered in TEMPLATE_FILES but exists on disk + * in the hook's directory (e.g. B1, R1, R2). + */ + private renderFromTemplatePath(hook: RegisteredHook, vars: Record): string | null { + if (!hook.templatePath || !existsSync(hook.templatePath)) return null; + const raw = readFileSync(hook.templatePath, 'utf-8'); + // Strip HTML comments (same logic as prompt-template-loader.stripComments) + const stripped = raw + .split('\n') + .filter((line) => !line.trimStart().startsWith('`, 'utf-8'); +} + +describe('HookRegistry', () => { + /** @type {typeof import('../dist/domains/prompt-hooks/HookRegistry.js').HookRegistry} */ + let HookRegistry; + + let testDir; + let testCounter = 0; + + beforeEach(async () => { + testCounter++; + testDir = join(FIXTURES_BASE, `run-${testCounter}`); + mkdirSync(testDir, { recursive: true }); + const mod = await import('../dist/domains/prompt-hooks/HookRegistry.js'); + HookRegistry = mod.HookRegistry; + }); + + afterEach(() => { + if (existsSync(FIXTURES_BASE)) { + rmSync(FIXTURES_BASE, { recursive: true, force: true }); + } + }); + + it('scans and registers hooks from directory', () => { + makeHook(testDir, 's1-identity', 'S1', 'session-init', 100); + makeHook(testDir, 'd1-anchor', 'D1', 'per-turn', 100); + + const registry = new HookRegistry(testDir); + const manifests = registry.scan(); + + assert.equal(manifests.length, 2); + assert.equal(registry.size, 2); + }); + + it('returns hooks by stage in order', () => { + makeHook(testDir, 's2-test', 'S2', 'session-init', 200); + makeHook(testDir, 's1-test', 'S1', 'session-init', 100); + makeHook(testDir, 'd1-test', 'D1', 'per-turn', 100); + + const registry = new HookRegistry(testDir); + registry.scan(); + + const sessionHooks = registry.getStageHooks('session-init'); + assert.equal(sessionHooks.length, 2); + assert.equal(sessionHooks[0].manifest.id, 'S1'); + assert.equal(sessionHooks[1].manifest.id, 'S2'); + + const turnHooks = registry.getStageHooks('per-turn'); + assert.equal(turnHooks.length, 1); + assert.equal(turnHooks[0].manifest.id, 'D1'); + }); + + it('getHook returns single hook by ID', () => { + makeHook(testDir, 's1-identity', 'S1', 'session-init', 100); + + const registry = new HookRegistry(testDir); + registry.scan(); + + const hook = registry.getHook('S1'); + assert.ok(hook); + assert.equal(hook.manifest.id, 'S1'); + assert.equal(hook.manifest.stage, 'session-init'); + + assert.equal(registry.getHook('NONEXISTENT'), undefined); + }); + + it('isEnabled reflects manifest baseline', () => { + makeHook(testDir, 's1-test', 'S1', 'session-init', 100, { enabled: true }); + makeHook(testDir, 's2-test', 'S2', 'session-init', 200, { enabled: false }); + + const registry = new HookRegistry(testDir); + registry.scan(); + + assert.equal(registry.isEnabled('S1'), true); + assert.equal(registry.isEnabled('S2'), false); + assert.equal(registry.isEnabled('NONEXISTENT'), false); + }); + + it('skips directories without hook.yaml', () => { + mkdirSync(join(testDir, 'not-a-hook'), { recursive: true }); + writeFileSync(join(testDir, 'not-a-hook', 'readme.md'), 'nope', 'utf-8'); + makeHook(testDir, 's1-test', 'S1', 'session-init', 100); + + const registry = new HookRegistry(testDir); + registry.scan(); + + assert.equal(registry.size, 1); + }); + + it('skips hooks with missing template file', () => { + const hookDir = join(testDir, 's1-test'); + mkdirSync(hookDir, { recursive: true }); + writeFileSync( + join(hookDir, 'hook.yaml'), + ` +id: S1 +name: Test +stage: session-init +order: 100 +version: 1 +enabled: true +template: nonexistent.md +inputs: [] +disableable: true +safetyTier: readonly +transparencyTier: visible-by-default +governanceTier: immutable +`, + 'utf-8', + ); + + const registry = new HookRegistry(testDir); + registry.scan(); + + assert.equal(registry.size, 0); + }); + + it('rejects duplicate order within same stage', () => { + makeHook(testDir, 's1-first', 'S1', 'session-init', 100); + makeHook(testDir, 's2-second', 'S2', 'session-init', 100); + + const registry = new HookRegistry(testDir); + registry.scan(); + + assert.equal(registry.size, 1); + }); + + it('returns empty for nonexistent directory', () => { + const registry = new HookRegistry('/nonexistent/path'); + const manifests = registry.scan(); + + assert.deepEqual(manifests, []); + assert.equal(registry.size, 0); + }); +}); diff --git a/packages/api/test/hook-resolver-registry.test.js b/packages/api/test/hook-resolver-registry.test.js new file mode 100644 index 0000000000..244d5e6a81 --- /dev/null +++ b/packages/api/test/hook-resolver-registry.test.js @@ -0,0 +1,57 @@ +/** + * F237 Phase 2-B: Resolver registry tests + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('ResolverRegistry', () => { + /** @type {typeof import('../dist/domains/prompt-hooks/resolvers/index.js')} */ + let registry; + + it('load module', async () => { + registry = await import('../dist/domains/prompt-hooks/resolvers/index.js'); + }); + + it('has exactly 46 resolvers registered', () => { + assert.equal(registry.RESOLVER_COUNT, 46); + }); + + it('covers all L-series (L1-L7)', () => { + for (let i = 1; i <= 7; i++) { + assert.ok(registry.getResolver(`L${i}`), `Missing resolver for L${i}`); + } + }); + + it('covers all S-series (S1-S13)', () => { + for (let i = 1; i <= 13; i++) { + assert.ok(registry.getResolver(`S${i}`), `Missing resolver for S${i}`); + } + }); + + it('covers all D-series (D1-D21)', () => { + for (let i = 1; i <= 21; i++) { + assert.ok(registry.getResolver(`D${i}`), `Missing resolver for D${i}`); + } + }); + + it('covers B1, C1, R1, R2, N1', () => { + for (const id of ['B1', 'C1', 'R1', 'R2', 'N1']) { + assert.ok(registry.getResolver(id), `Missing resolver for ${id}`); + } + }); + + it('returns undefined for unknown hook IDs', () => { + assert.equal(registry.getResolver('Z99'), undefined); + assert.equal(registry.getResolver('NONEXISTENT'), undefined); + }); + + it('all resolvers implement resolve()', () => { + const ids = registry.getRegisteredResolverIds(); + assert.equal(ids.length, 46); + for (const id of ids) { + const resolver = registry.getResolver(id); + assert.equal(typeof resolver.resolve, 'function', `${id} resolver missing resolve()`); + } + }); +}); diff --git a/packages/api/test/hook-resolvers-session.test.js b/packages/api/test/hook-resolvers-session.test.js new file mode 100644 index 0000000000..71bfe3dbaf --- /dev/null +++ b/packages/api/test/hook-resolvers-session.test.js @@ -0,0 +1,228 @@ +/** + * F237 Phase 2-B: Session-init resolver tests (L1-L7, S1-S13, B1, C1) + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +/** @returns {import('@cat-cafe/shared').AssemblerInput} */ +function makeInput(overrides = {}) { + return { + catId: 'opus', + catConfig: { + displayName: '布偶猫', + nickname: '宪宪', + name: 'Ragdoll', + roleDescription: '主架构师', + personality: '温柔但有主见', + defaultModel: 'claude-opus-4-6', + variantLabel: undefined, + mentionPatterns: ['@opus', '@布偶猫'], + restrictions: [], + clientId: 'anthropic', + breedId: 'ragdoll', + }, + runtimeModel: 'claude-opus-4-6', + providerLabel: 'Anthropic', + callableMentions: { mentions: ['@codex', '@gemini'], hasDuplicateDisplayNames: false, uniqueHandleExample: null }, + rosterContent: '| 猫猫 | @mention | 擅长 | 注意 |\n|---|---|---|---|\n| 缅因猫 | @codex | Review | — |', + workflowTriggerContent: null, + coCreatorName: 'lang', + coCreatorHandles: '`@lang` / `@co-creator`', + governanceDigest: '## 家规摘要\n- 规则1\n- 规则2', + mcpToolsSection: '## MCP Tools\n- tool1\n- tool2', + packMasksBlock: null, + packWorkflowsBlock: null, + packGuardrailBlock: null, + packDefaultsBlock: null, + packWorldDriverSummary: null, + mode: 'independent', + chainIndex: null, + chainTotal: null, + mcpAvailable: true, + nativeL0Injected: false, + a2aEnabled: false, + directMessage: null, + crossThreadReplyHint: null, + pingPongWarning: null, + teammates: [], + mentionRoutingItems: [], + promptTags: [], + activeParticipants: [], + routingPolicyParts: null, + sopStageHint: null, + voiceMode: false, + bootcampState: null, + threadId: null, + bootcampMemberCount: null, + guidePromptLines: null, + conciergeLines: null, + worldContext: null, + alwaysOnDocsBlock: null, + activeSignalsBlock: null, + a2aBallCheckContent: null, + handoffDecisionTreeContent: null, + coCreatorFirstMention: '@lang', + ...overrides, + }; +} + +describe('Layer resolvers (L1-L7)', () => { + it('L1-L7 always fire with empty vars', async () => { + const { L1Resolver, L2Resolver, L3Resolver, L4Resolver, L5Resolver, L6Resolver, L7Resolver } = await import( + '../dist/domains/prompt-hooks/resolvers/layer-resolvers.js' + ); + const input = makeInput(); + for (const Cls of [L1Resolver, L2Resolver, L3Resolver, L4Resolver, L5Resolver, L6Resolver, L7Resolver]) { + const r = new Cls(); + const result = r.resolve(input); + assert.equal(result.status, 'fired'); + assert.deepEqual(result.vars, {}); + } + }); +}); + +describe('Session resolvers (S1-S13)', () => { + /** @type {typeof import('../dist/domains/prompt-hooks/resolvers/session-resolvers.js')} */ + let mod; + + it('load module', async () => { + mod = await import('../dist/domains/prompt-hooks/resolvers/session-resolvers.js'); + }); + + it('S1 always fires with identity vars', () => { + const r = new mod.S1Resolver(); + const result = r.resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.NAME_LABEL.includes('宪宪')); + assert.ok(result.vars.NAME_LABEL.includes('布偶猫')); + assert.equal(result.vars.PROVIDER_LABEL, 'Anthropic'); + assert.ok(result.vars.NICKNAME_ORIGIN.includes('宪宪')); + assert.equal(result.vars.ROLE_DESCRIPTION, '主架构师'); + }); + + it('S1 without nickname omits nickname parts', () => { + const r = new mod.S1Resolver(); + const input = makeInput({ catConfig: { ...makeInput().catConfig, nickname: undefined } }); + const result = r.resolve(input); + assert.equal(result.status, 'fired'); + assert.ok(!result.vars.NAME_LABEL.includes('/')); + assert.equal(result.vars.NICKNAME_ORIGIN, ''); + }); + + it('S2 fires when restrictions present', () => { + const r = new mod.S2Resolver(); + const input = makeInput({ catConfig: { ...makeInput().catConfig, restrictions: ['禁止写代码', '禁止审批'] } }); + const result = r.resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.RESTRICTIONS_TEXT, '禁止写代码、禁止审批'); + }); + + it('S2 skips when no restrictions', () => { + const r = new mod.S2Resolver(); + assert.equal(r.resolve(makeInput()).status, 'skipped'); + }); + + it('S3 fires with pack masks', () => { + const r = new mod.S3Resolver(); + const result = r.resolve(makeInput({ packMasksBlock: '## Masks\n- mask1' })); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.PACK_MASKS_BLOCK.includes('mask1')); + }); + + it('S3 skips without pack masks', () => { + assert.equal(new mod.S3Resolver().resolve(makeInput()).status, 'skipped'); + }); + + it('S4 fires with callable mentions', () => { + const r = new mod.S4Resolver(); + const result = r.resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.CALLABLE_MENTIONS.includes('@codex')); + assert.equal(result.vars.EXAMPLE_TARGET, '@codex'); + }); + + it('S4 skips with empty mentions', () => { + const input = makeInput({ + callableMentions: { mentions: [], hasDuplicateDisplayNames: false, uniqueHandleExample: null }, + }); + assert.equal(new mod.S4Resolver().resolve(input).status, 'skipped'); + }); + + it('S4 includes duplicate hint when hasDuplicateDisplayNames', () => { + const input = makeInput({ + callableMentions: { + mentions: ['@opus', '@opus45'], + hasDuplicateDisplayNames: true, + uniqueHandleExample: '@opus45', + }, + }); + const result = new mod.S4Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.DUPLICATE_NAMES_HINT.includes('@opus45')); + }); + + it('S5 fires with roster', () => { + const result = new mod.S5Resolver().resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.ROSTER_CONTENT.includes('缅因猫')); + }); + + it('S5 skips without roster', () => { + assert.equal(new mod.S5Resolver().resolve(makeInput({ rosterContent: null })).status, 'skipped'); + }); + + it('S6 fires with workflow triggers', () => { + const input = makeInput({ workflowTriggerContent: '## 工作流\n- review done → @codex' }); + const result = new mod.S6Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.CONTENT.includes('review done')); + }); + + it('S6 skips without triggers', () => { + assert.equal(new mod.S6Resolver().resolve(makeInput()).status, 'skipped'); + }); + + it('S7 fires with pack workflows', () => { + const input = makeInput({ packWorkflowsBlock: '## Pack WF' }); + assert.equal(new mod.S7Resolver().resolve(input).status, 'fired'); + }); + + it('S8 always fires with co-creator info', () => { + const result = new mod.S8Resolver().resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.CC_NAME, 'lang'); + }); + + it('S9 always fires with governance digest', () => { + const result = new mod.S9Resolver().resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.GOVERNANCE_DIGEST.includes('家规')); + }); + + it('S10-S12 conditional on pack blocks', () => { + const input = makeInput({ + packGuardrailBlock: 'guard', + packDefaultsBlock: 'defaults', + packWorldDriverSummary: 'world', + }); + assert.equal(new mod.S10Resolver().resolve(input).status, 'fired'); + assert.equal(new mod.S11Resolver().resolve(input).status, 'fired'); + assert.equal(new mod.S12Resolver().resolve(input).status, 'fired'); + // All skip without pack blocks + assert.equal(new mod.S10Resolver().resolve(makeInput()).status, 'skipped'); + assert.equal(new mod.S11Resolver().resolve(makeInput()).status, 'skipped'); + assert.equal(new mod.S12Resolver().resolve(makeInput()).status, 'skipped'); + }); + + it('S13 fires when MCP available', () => { + assert.equal(new mod.S13Resolver().resolve(makeInput({ mcpAvailable: true })).status, 'fired'); + assert.equal(new mod.S13Resolver().resolve(makeInput({ mcpAvailable: false })).status, 'skipped'); + }); + + it('B1 and C1 always fire', () => { + const input = makeInput(); + assert.equal(new mod.B1Resolver().resolve(input).status, 'fired'); + assert.equal(new mod.C1Resolver().resolve(input).status, 'fired'); + }); +}); diff --git a/packages/api/test/hook-resolvers-turn.test.js b/packages/api/test/hook-resolvers-turn.test.js new file mode 100644 index 0000000000..6021742005 --- /dev/null +++ b/packages/api/test/hook-resolvers-turn.test.js @@ -0,0 +1,361 @@ +/** + * F237 Phase 2-B: Per-turn resolver tests (D1-D21, R1-R2, N1) + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +/** @returns {import('@cat-cafe/shared').AssemblerInput} */ +function makeInput(overrides = {}) { + return { + catId: 'opus', + catConfig: { + displayName: '布偶猫', + nickname: '宪宪', + name: 'Ragdoll', + roleDescription: '主架构师', + personality: '温柔但有主见', + defaultModel: 'claude-opus-4-6', + variantLabel: undefined, + mentionPatterns: ['@opus'], + restrictions: [], + clientId: 'anthropic', + }, + runtimeModel: 'claude-opus-4-6', + providerLabel: 'Anthropic', + callableMentions: { mentions: [], hasDuplicateDisplayNames: false, uniqueHandleExample: null }, + rosterContent: null, + workflowTriggerContent: null, + coCreatorName: 'lang', + coCreatorHandles: '`@lang`', + governanceDigest: '', + mcpToolsSection: '', + packMasksBlock: null, + packWorkflowsBlock: null, + packGuardrailBlock: null, + packDefaultsBlock: null, + packWorldDriverSummary: null, + mode: 'independent', + chainIndex: null, + chainTotal: null, + mcpAvailable: false, + nativeL0Injected: false, + a2aEnabled: false, + directMessage: null, + crossThreadReplyHint: null, + pingPongWarning: null, + teammates: [], + mentionRoutingItems: [], + promptTags: [], + activeParticipants: [], + routingPolicyParts: null, + sopStageHint: null, + voiceMode: false, + bootcampState: null, + threadId: null, + bootcampMemberCount: null, + guidePromptLines: null, + conciergeLines: null, + worldContext: null, + alwaysOnDocsBlock: null, + activeSignalsBlock: null, + a2aBallCheckContent: null, + handoffDecisionTreeContent: null, + coCreatorFirstMention: '@lang', + ...overrides, + }; +} + +describe('Turn resolvers D1-D10', () => { + /** @type {typeof import('../dist/domains/prompt-hooks/resolvers/turn-resolvers-a.js')} */ + let mod; + + it('load module', async () => { + mod = await import('../dist/domains/prompt-hooks/resolvers/turn-resolvers-a.js'); + }); + + it('D1 always fires with identity anchor', () => { + const result = new mod.D1Resolver().resolve(makeInput()); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.DISPLAY_NAME, '布偶猫'); + assert.equal(result.vars.NICKNAME_PART, '/宪宪'); + assert.equal(result.vars.CAT_ID, 'opus'); + assert.equal(result.vars.RUNTIME_MODEL, 'claude-opus-4-6'); + }); + + it('D2 fires with direct message', () => { + const input = makeInput({ + directMessage: { + fromCatId: 'codex', + fromLabel: '缅因猫(codex)', + fromModel: 'gpt-5.5', + fromDisplayName: '缅因猫', + isSameBreed: false, + }, + }); + const result = new mod.D2Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.FROM_LABEL, '缅因猫(codex)'); + assert.equal(result.vars.FROM_MODEL, 'gpt-5.5'); + }); + + it('D2 skips without direct message', () => { + assert.equal(new mod.D2Resolver().resolve(makeInput()).status, 'skipped'); + }); + + it('D3 fires for same-breed handoff', () => { + const input = makeInput({ + directMessage: { + fromCatId: 'opus45', + fromLabel: '布偶猫 Opus 4.5(opus45)', + fromModel: 'claude-opus-4-5', + fromDisplayName: '布偶猫', + fromVariantLabel: 'Opus 4.5', + isSameBreed: true, + }, + }); + const result = new mod.D3Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.FROM_VARIANT, 'Opus 4.5'); + }); + + it('D3 skips for cross-breed handoff', () => { + const input = makeInput({ + directMessage: { + fromCatId: 'codex', + fromLabel: '缅因猫(codex)', + fromModel: 'gpt-5.5', + fromDisplayName: '缅因猫', + isSameBreed: false, + }, + }); + assert.equal(new mod.D3Resolver().resolve(input).status, 'skipped'); + }); + + it('D4 fires with cross-thread hint', () => { + const input = makeInput({ + crossThreadReplyHint: { sourceThreadId: 'thread-abc', senderCatId: 'codex', effectClass: 'fyi' }, + }); + const result = new mod.D4Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.SOURCE_THREAD, 'thread-abc'); + assert.ok(result.vars.CONSTRAINT_TEXT.includes('effect=fyi')); + }); + + it('D4 skips without cross-thread hint', () => { + assert.equal(new mod.D4Resolver().resolve(makeInput()).status, 'skipped'); + }); + + it('D5 fires with ping-pong warning', () => { + const input = makeInput({ pingPongWarning: { otherLabel: '缅因猫(codex)', count: 3 } }); + const result = new mod.D5Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.STREAK_COUNT, '3'); + }); + + it('D6 fires with teammates', () => { + const input = makeInput({ + teammates: [ + { id: 'codex', displayName: '缅因猫', nickname: '砚砚', name: 'Maine Coon', roleDescription: 'Review' }, + ], + }); + const result = new mod.D6Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.TEAMMATES_LIST.includes('缅因猫/砚砚')); + }); + + it('D7 fires serial mode', () => { + const input = makeInput({ mode: 'serial', chainIndex: 2, chainTotal: 3 }); + const result = new mod.D7Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.TEMPLATE_VARIANT, 'D7_serial'); + assert.equal(result.vars.CHAIN_INDEX, '2'); + }); + + it('D7 fires parallel mode', () => { + const input = makeInput({ mode: 'parallel' }); + const result = new mod.D7Resolver().resolve(input); + assert.equal(result.vars.TEMPLATE_VARIANT, 'D7_parallel'); + }); + + it('D7 fires solo mode', () => { + const result = new mod.D7Resolver().resolve(makeInput()); + assert.equal(result.vars.TEMPLATE_VARIANT, 'D7_solo'); + }); + + it('D8 fires when a2a needed and not native L0', () => { + const input = makeInput({ + mode: 'serial', + a2aEnabled: true, + nativeL0Injected: false, + a2aBallCheckContent: '## Ball Check', + }); + const result = new mod.D8Resolver().resolve(input); + assert.equal(result.status, 'fired'); + }); + + it('D8 skips in parallel mode', () => { + const input = makeInput({ mode: 'parallel', a2aEnabled: true, a2aBallCheckContent: '## Ball Check' }); + assert.equal(new mod.D8Resolver().resolve(input).status, 'skipped'); + }); + + it('D8 skips when native L0 injected', () => { + const input = makeInput({ mode: 'serial', a2aEnabled: true, nativeL0Injected: true }); + assert.equal(new mod.D8Resolver().resolve(input).status, 'skipped'); + }); + + it('D9 fires with routing feedback items', () => { + const input = makeInput({ mentionRoutingItems: ['@unknown1', '@unknown2', '@unknown3'] }); + const result = new mod.D9Resolver().resolve(input); + assert.equal(result.status, 'fired'); + // Only first 2 items + assert.equal(result.vars.UNROUTED_MENTIONS, '@unknown1、@unknown2'); + }); + + it('D10 fires with critique tag', () => { + const input = makeInput({ promptTags: ['critique'] }); + assert.equal(new mod.D10Resolver().resolve(input).status, 'fired'); + }); + + it('D10 skips without critique tag', () => { + assert.equal(new mod.D10Resolver().resolve(makeInput()).status, 'skipped'); + }); +}); + +describe('Turn resolvers D11-D21, R1-R2, N1', () => { + /** @type {typeof import('../dist/domains/prompt-hooks/resolvers/turn-resolvers-b.js')} */ + let mod; + + it('load module', async () => { + mod = await import('../dist/domains/prompt-hooks/resolvers/turn-resolvers-b.js'); + }); + + it('D11 fires with skill tag', () => { + const input = makeInput({ promptTags: ['skill:tdd'] }); + const result = new mod.D11Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.SKILL_NAME, 'tdd'); + }); + + it('D12 fires with qualifying active participant', () => { + const input = makeInput({ + activeParticipants: [ + { catId: 'codex', label: '缅因猫(codex)', lastMessageAt: 1000 }, + { catId: 'opus', label: '布偶猫(opus)', lastMessageAt: 2000 }, + ], + }); + const result = new mod.D12Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.ACTIVE_LABEL, '缅因猫(codex)'); + }); + + it('D12 skips when only self is active', () => { + const input = makeInput({ + activeParticipants: [{ catId: 'opus', label: '布偶猫(opus)', lastMessageAt: 1000 }], + }); + assert.equal(new mod.D12Resolver().resolve(input).status, 'skipped'); + }); + + it('D13 fires with routing policy', () => { + const input = makeInput({ routingPolicyParts: 'review avoid @codex (recent conflict)' }); + const result = new mod.D13Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.ok(result.vars.ROUTING_PARTS.includes('avoid @codex')); + }); + + it('D14 fires with SOP hint', () => { + const input = makeInput({ + sopStageHint: { + featureId: 'F237', + stage: 'implement', + suggestedSkill: 'tdd', + suggestedSkillSource: 'mission-hub', + }, + }); + const result = new mod.D14Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.FEATURE_ID, 'F237'); + assert.ok(result.vars.SOURCE_PART.includes('mission-hub')); + }); + + it('D15 always fires — voice on', () => { + const result = new mod.D15Resolver().resolve(makeInput({ voiceMode: true })); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.TEMPLATE_VARIANT, 'D15_on'); + }); + + it('D15 always fires — voice off', () => { + const result = new mod.D15Resolver().resolve(makeInput()); + assert.equal(result.vars.TEMPLATE_VARIANT, 'D15_off'); + }); + + it('D16 fires with bootcamp state', () => { + const input = makeInput({ + bootcampState: { phase: 'explore', leadCat: 'opus', selectedTaskId: 'task-1' }, + threadId: 'thread-abc', + bootcampMemberCount: 3, + }); + const result = new mod.D16Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.PHASE, 'explore'); + assert.ok(result.vars.THREAD_PART.includes('thread-abc')); + assert.ok(result.vars.MEMBERS_PART.includes('3')); + }); + + it('D17 fires with guide lines', () => { + const input = makeInput({ guidePromptLines: '## Guide: Getting Started\nStep 1...' }); + assert.equal(new mod.D17Resolver().resolve(input).status, 'fired'); + }); + + it('D18 fires with world context', () => { + const input = makeInput({ + worldContext: { + worldName: 'Testworld', + worldStatus: 'active', + constitutionLine: 'Constitution: Be kind', + sceneName: 'Scene1', + sceneStatus: 'active', + charactersBlock: 'Characters:\n- Alice', + canonBlock: '', + recentEventsBlock: '', + careHintLine: '', + }, + }); + const result = new mod.D18Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.WORLD_NAME, 'Testworld'); + }); + + it('D19 fires with always-on docs', () => { + const input = makeInput({ alwaysOnDocsBlock: '### Doc1\n\nContent' }); + assert.equal(new mod.D19Resolver().resolve(input).status, 'fired'); + }); + + it('D20 fires with signals', () => { + const input = makeInput({ activeSignalsBlock: '### [S1] Title (HN/T1)\nContent' }); + assert.equal(new mod.D20Resolver().resolve(input).status, 'fired'); + }); + + it('D21 fires when a2a needed and returns CC_MENTION', () => { + const input = makeInput({ + mode: 'serial', + a2aEnabled: true, + nativeL0Injected: false, + }); + const result = new mod.D21Resolver().resolve(input); + assert.equal(result.status, 'fired'); + assert.equal(result.vars.CC_MENTION, '@lang'); + }); + + it('D21 skips when native L0', () => { + const input = makeInput({ mode: 'serial', a2aEnabled: true, nativeL0Injected: true }); + assert.equal(new mod.D21Resolver().resolve(input).status, 'skipped'); + }); + + it('R1, R2, N1 always fire', () => { + const input = makeInput(); + assert.equal(new mod.R1Resolver().resolve(input).status, 'fired'); + assert.equal(new mod.R2Resolver().resolve(input).status, 'fired'); + assert.equal(new mod.N1Resolver().resolve(input).status, 'fired'); + }); +}); diff --git a/packages/api/test/l0-pipeline-equivalence.test.js b/packages/api/test/l0-pipeline-equivalence.test.js new file mode 100644 index 0000000000..b917cb451f --- /dev/null +++ b/packages/api/test/l0-pipeline-equivalence.test.js @@ -0,0 +1,148 @@ +/** + * F237 Phase 2 AC-P2-14a: L0 compiler ↔ pipeline L-hook equivalence. + * + * Proves that the L0 compiler's loadL0SectionTemplate() output for L1-L7 + * matches the pipeline's L-hook patch content (whitespace-normalized). + * + * When this test passes, the L0 compiler can safely switch to consuming + * pipeline-produced L-hook content instead of reading template files directly. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +/** Normalize whitespace for comparison. */ +function normalize(s) { + return s.replace(/\s+/g, ' ').trim(); +} + +/** Strip compiler-only annotation lines (same as L0 compiler's logic). */ +function stripAnnotations(raw) { + const SEGMENT_LABEL = /^── \[[A-Z]\d+] .+──$/; + return raw + .split('\n') + .filter((line) => { + const trimmed = line.trim(); + return !trimmed.startsWith('\n\n`, + 'utf-8', + ); + } + + created++; +} + +console.log(`Generated ${created} hook directories, skipped ${skipped} existing.`); +console.log(`Total: ${TIER1_HOOKS.length} Tier 1 hooks.`); +console.log(`Session-init: ${TIER1_HOOKS.filter((h) => h.stage === 'session-init').length}`); +console.log(`Per-turn: ${TIER1_HOOKS.filter((h) => h.stage === 'per-turn').length}`); + +function slugify(name) { + return name + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} From 27445de88ec5e6ddc616e5fc9f196ea23f43e0c9 Mon Sep 17 00:00:00 2001 From: labulalala <125251981+labulalala@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:14:14 +0800 Subject: [PATCH 08/15] fix(windows): hide gh CLI child windows to prevent console flash (#1129) Add shared withHiddenGhCliWindow() option constructor that forces windowsHide: true on every Node child-process invocation of gh CLI, and apply it to the direct gh calls across the API. This prevents transient conhost/OpenConsole flashes on Windows when gh telemetry launches child processes. Also aligns the remaining index.ts sync gh calls with getGitHubExecOptions() token isolation. Fixes #1130 Source PR: #1129 Head: fdeead58464cc7b79cd4f8ae62cd188a66f5b8fe Co-authored-by: Claude --- .../domains/feat-trajectory/RealGhClient.ts | 11 ++-- packages/api/src/index.ts | 14 ++--- .../email/ConflictAutoExecutor.ts | 3 +- .../infrastructure/email/ci-status-fetcher.ts | 16 +++--- .../infrastructure/github/fetch-paginated.ts | 24 ++++++--- .../src/infrastructure/github/gh-cli-env.ts | 10 ++++ .../publish-verdict/git-worktree-publisher.ts | 9 ++-- packages/api/test/fetch-paginated.test.js | 1 + packages/api/test/gh-cli-env.test.js | 16 +++++- .../api/test/gh-cli-window-policy.test.js | 52 +++++++++++++++++++ 10 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 packages/api/test/gh-cli-window-policy.test.js diff --git a/packages/api/src/domains/feat-trajectory/RealGhClient.ts b/packages/api/src/domains/feat-trajectory/RealGhClient.ts index 77e5dbbb8c..dbb6ab2002 100644 --- a/packages/api/src/domains/feat-trajectory/RealGhClient.ts +++ b/packages/api/src/domains/feat-trajectory/RealGhClient.ts @@ -20,6 +20,7 @@ */ import { spawn } from 'node:child_process'; +import { withHiddenGhCliWindow } from '../../infrastructure/github/gh-cli-env.js'; import type { GhClient, PrInfo } from './GitRefSnapshotCollector.js'; /** 注入式 gh 命令执行器 — 返回 stdout 字符串,非零退出抛错。 */ @@ -148,9 +149,13 @@ export class RealGhClient implements GhClient { /** Default real spawn-based gh command runner (production). */ const defaultGhCmd: GhCmdRunner = (args) => new Promise((resolve, reject) => { - const proc = spawn('gh', args as readonly string[], { - env: { ...process.env, GH_PROMPT_DISABLED: '1' }, - }); + const proc = spawn( + 'gh', + args as readonly string[], + withHiddenGhCliWindow({ + env: { ...process.env, GH_PROMPT_DISABLED: '1' }, + }), + ); let stdout = ''; let stderr = ''; proc.stdout.on('data', (d: Buffer) => { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 469c3116b8..8da674171a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -157,7 +157,7 @@ import { ReviewFeedbackRouter, } from './infrastructure/email/index.js'; import { fetchLatestIssueCommentCursor, maxGithubId } from './infrastructure/github/comment-cursors.js'; -import { buildGhCliEnv, resolveGhCliToken } from './infrastructure/github/gh-cli-env.js'; +import { buildGhCliEnv, resolveGhCliToken, withHiddenGhCliWindow } from './infrastructure/github/gh-cli-env.js'; import type { EvalDomainId } from './infrastructure/harness-eval/domain/eval-domain-registry.js'; import { runSchedulerReplyUserIdBackfill } from './infrastructure/scheduler/scheduler-reply-userid-backfill.js'; import { securityHeadersPlugin } from './infrastructure/security-headers.js'; @@ -2248,11 +2248,11 @@ async function main(): Promise { const getGitHubToken = (): string | undefined => { return resolveGhCliToken({ pluginEnv: getGitHubPluginEnv() }); }; - const getGitHubExecOptions = (timeout: number): { timeout: number; env?: NodeJS.ProcessEnv } => { - return { + const getGitHubExecOptions = (timeout: number): { timeout: number; env?: NodeJS.ProcessEnv; windowsHide: true } => { + return withHiddenGhCliWindow({ timeout, env: buildGhCliEnv({ token: getGitHubToken() }), - }; + }); }; const { createRepoActivityTemplate } = await import('./infrastructure/scheduler/templates/repo-activity.js'); templateRegistry.register(createRepoActivityTemplate({ getGitHubToken })); @@ -2889,7 +2889,7 @@ async function main(): Promise { '-f', 'per_page=100', ], - { timeout: 60_000 }, + getGitHubExecOptions(60_000), ); if (!stdout.trim()) return []; return stdout @@ -2916,7 +2916,7 @@ async function main(): Promise { '-f', 'per_page=100', ], - { timeout: 60_000 }, + getGitHubExecOptions(60_000), ); if (!stdout.trim()) return []; return stdout @@ -2939,7 +2939,7 @@ async function main(): Promise { '--jq', '.[] | {user: .user.login, state, commit_id}', ], - { timeout: 30_000 }, + getGitHubExecOptions(30_000), ); if (!stdout.trim()) return []; return stdout diff --git a/packages/api/src/infrastructure/email/ConflictAutoExecutor.ts b/packages/api/src/infrastructure/email/ConflictAutoExecutor.ts index 6d00889c29..750d56a7d2 100644 --- a/packages/api/src/infrastructure/email/ConflictAutoExecutor.ts +++ b/packages/api/src/infrastructure/email/ConflictAutoExecutor.ts @@ -9,6 +9,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import type { FastifyBaseLogger } from 'fastify'; import { listWorktrees } from '../../domains/workspace/workspace-security.js'; +import { withHiddenGhCliWindow } from '../github/gh-cli-env.js'; const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 30_000; @@ -101,7 +102,7 @@ export class ConflictAutoExecutor { const { stdout } = await execFileAsync( 'gh', ['api', `repos/${repoFullName}/pulls/${prNumber}`, '--jq', '.head.ref'], - { timeout: GH_TIMEOUT_MS }, + withHiddenGhCliWindow({ timeout: GH_TIMEOUT_MS }), ); return stdout.trim() || null; } catch { diff --git a/packages/api/src/infrastructure/email/ci-status-fetcher.ts b/packages/api/src/infrastructure/email/ci-status-fetcher.ts index 42a7e8fe2d..5a11b0f988 100644 --- a/packages/api/src/infrastructure/email/ci-status-fetcher.ts +++ b/packages/api/src/infrastructure/email/ci-status-fetcher.ts @@ -4,7 +4,7 @@ */ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { buildGhCliEnv } from '../github/gh-cli-env.js'; +import { buildGhCliEnv, withHiddenGhCliWindow } from '../github/gh-cli-env.js'; import type { CiBucket, CiCheckDetail, CiPollResult } from './CiCdRouter.js'; const execFileAsync = promisify(execFile); @@ -30,7 +30,7 @@ export async function fetchPrCiStatus( const { stdout } = await execFileAsync( 'gh', ['pr', 'view', String(prNumber), '-R', repoFullName, '--json', 'headRefOid,state,mergedAt,statusCheckRollup'], - { timeout: GH_TIMEOUT_MS, env: buildGhCliEnv({ token: options.ghToken }) }, + withHiddenGhCliWindow({ timeout: GH_TIMEOUT_MS, env: buildGhCliEnv({ token: options.ghToken }) }), ); prViewJson = stdout; } catch (err) { @@ -86,10 +86,14 @@ async function fetchCheckDetails( ]; if (requiredFlag) args.push(requiredFlag); - const { stdout } = await execFileAsync('gh', args, { - timeout: GH_TIMEOUT_MS, - env: buildGhCliEnv({ token: options.ghToken }), - }); + const { stdout } = await execFileAsync( + 'gh', + args, + withHiddenGhCliWindow({ + timeout: GH_TIMEOUT_MS, + env: buildGhCliEnv({ token: options.ghToken }), + }), + ); const parsed: Array<{ name: string; bucket: string; link?: string; workflow?: string; description?: string }> = JSON.parse(stdout); diff --git a/packages/api/src/infrastructure/github/fetch-paginated.ts b/packages/api/src/infrastructure/github/fetch-paginated.ts index 6115b6fdf8..5f7cd47089 100644 --- a/packages/api/src/infrastructure/github/fetch-paginated.ts +++ b/packages/api/src/infrastructure/github/fetch-paginated.ts @@ -14,7 +14,7 @@ * support `since`/`direction` params, so we still scan all pages * client-side. A future optimization could use GraphQL `last:N`. */ -import { buildGhCliEnv } from './gh-cli-env.js'; +import { buildGhCliEnv, withHiddenGhCliWindow } from './gh-cli-env.js'; export interface FetchPaginatedOptions { /** Items with id > sinceId are collected. 0 or omitted = collect all. */ @@ -25,7 +25,7 @@ export interface FetchPaginatedOptions { execFileAsync?: ( file: string, args: string[], - opts: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv }, + opts: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; windowsHide: boolean }, ) => Promise<{ stdout: string }>; } @@ -42,7 +42,11 @@ export async function fetchPaginated(endpoint: string, options: FetchPaginatedOp const { sinceId, ghToken, execFileAsync: execOverride } = options; const execFn = execOverride ?? - (async (file: string, args: string[], opts: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv }) => { + (async ( + file: string, + args: string[], + opts: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; windowsHide: boolean }, + ) => { const { execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); return promisify(execFile)(file, args, opts); @@ -54,11 +58,15 @@ export async function fetchPaginated(endpoint: string, options: FetchPaginatedOp let page = 1; while (true) { - const { stdout } = await execFn('gh', ['api', `${endpoint}?per_page=100&page=${page}`, '--jq', '.[]'], { - timeout: 15_000, - maxBuffer: 2 * 1024 * 1024, - env: buildGhCliEnv({ token: ghToken }), - }); + const { stdout } = await execFn( + 'gh', + ['api', `${endpoint}?per_page=100&page=${page}`, '--jq', '.[]'], + withHiddenGhCliWindow({ + timeout: 15_000, + maxBuffer: 2 * 1024 * 1024, + env: buildGhCliEnv({ token: ghToken }), + }), + ); if (!stdout.trim()) break; // empty page = no more data const items = stdout diff --git a/packages/api/src/infrastructure/github/gh-cli-env.ts b/packages/api/src/infrastructure/github/gh-cli-env.ts index db78a66109..7b5958dcd1 100644 --- a/packages/api/src/infrastructure/github/gh-cli-env.ts +++ b/packages/api/src/infrastructure/github/gh-cli-env.ts @@ -43,3 +43,13 @@ export function buildGhCliEnv(options: GhCliEnvOptions = {}): NodeJS.ProcessEnv return env; } + +/** + * Keep `gh` subprocesses from creating transient console windows on Windows. + * + * Apply this to every Node child-process invocation of `gh`. The final + * assignment intentionally overrides a caller-provided false value. + */ +export function withHiddenGhCliWindow(options: T): T & { readonly windowsHide: true } { + return { ...options, windowsHide: true }; +} diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts index b6531e688c..5600ea0516 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; +import { withHiddenGhCliWindow } from '../../github/gh-cli-env.js'; import type { GitPublisher, PublishOnIsolatedWorktreeOpts } from './publish-verdict.js'; const exec = promisify(execFile); @@ -125,7 +126,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP args.push('--color', meta.color, '--description', meta.description); } try { - await exec('gh', args, { cwd: worktreePath, timeout: 15_000 }); + await exec('gh', args, withHiddenGhCliWindow({ cwd: worktreePath, timeout: 15_000 })); } catch (err) { // Best-effort: surface error on gh pr create below if it actually breaks PR. // (Swallowing here = avoid double-fail on label step; PR create will retry.) @@ -148,7 +149,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP prBody, ...labelFlags, ], - { cwd: worktreePath, timeout: 60_000 }, + withHiddenGhCliWindow({ cwd: worktreePath, timeout: 60_000 }), ); prUrl = prResult.stdout @@ -172,7 +173,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP '--comment', 'Closing stale auto-verdict PR because post-publish writeback failed.', ], - { cwd: worktreePath, timeout: 60_000 }, + withHiddenGhCliWindow({ cwd: worktreePath, timeout: 60_000 }), ); prOpened = false; } catch (cleanupErr) { @@ -226,7 +227,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const probe = await exec( 'gh', ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], - { cwd: deps.repoRoot, timeout: 30_000 }, + withHiddenGhCliWindow({ cwd: deps.repoRoot, timeout: 30_000 }), ); const parsed = JSON.parse(probe.stdout) as Array<{ state?: string }>; if (Array.isArray(parsed) && parsed.length === 0) safeToDelete = true; diff --git a/packages/api/test/fetch-paginated.test.js b/packages/api/test/fetch-paginated.test.js index fea58f5b3d..1d1e556123 100644 --- a/packages/api/test/fetch-paginated.test.js +++ b/packages/api/test/fetch-paginated.test.js @@ -123,6 +123,7 @@ describe('fetchPaginated', () => { const items = await fetchPaginated('/repos/o/r/issues/1/comments', { execFileAsync: fn }); assert.equal(items.length, 1); + assert.equal(calls()[0].opts.windowsHide, true); assert.equal(calls()[0].opts.env.GITHUB_TOKEN, undefined); assert.equal(calls()[0].opts.env.GH_TOKEN, undefined); assert.equal(process.env.GITHUB_TOKEN, 'ambient-token-that-gh-must-not-see'); diff --git a/packages/api/test/gh-cli-env.test.js b/packages/api/test/gh-cli-env.test.js index 3f625dd08c..a538ce9681 100644 --- a/packages/api/test/gh-cli-env.test.js +++ b/packages/api/test/gh-cli-env.test.js @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -const { buildGhCliEnv, resolveGhCliToken } = await import('../dist/infrastructure/github/gh-cli-env.js'); +const { buildGhCliEnv, resolveGhCliToken, withHiddenGhCliWindow } = await import( + '../dist/infrastructure/github/gh-cli-env.js' +); describe('buildGhCliEnv', () => { it('strips ambient GitHub token env when no explicit token is provided', () => { @@ -102,3 +104,15 @@ describe('buildGhCliEnv', () => { assert.equal(env.GH_TOKEN, undefined); }); }); + +describe('withHiddenGhCliWindow', () => { + it('forces gh child processes to stay hidden on Windows', () => { + const options = withHiddenGhCliWindow({ + timeout: 15_000, + windowsHide: false, + }); + + assert.equal(options.timeout, 15_000); + assert.equal(options.windowsHide, true); + }); +}); diff --git a/packages/api/test/gh-cli-window-policy.test.js b/packages/api/test/gh-cli-window-policy.test.js new file mode 100644 index 0000000000..7311b60b4f --- /dev/null +++ b/packages/api/test/gh-cli-window-policy.test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const API_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SOURCE_ROOT = join(API_ROOT, 'src'); + +function collectTypeScriptFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(path); + return entry.isFile() && entry.name.endsWith('.ts') ? [path] : []; + }); +} + +describe('gh CLI child-process policy', () => { + it('hides every direct Node gh invocation on Windows', () => { + const ghCalls = []; + + for (const path of collectTypeScriptFiles(SOURCE_ROOT)) { + const sourceText = readFileSync(path, 'utf8'); + const source = ts.createSourceFile(path, sourceText, ts.ScriptTarget.Latest, true); + + function visit(node) { + if (ts.isCallExpression(node) && node.arguments.length >= 3) { + const command = node.arguments[0]; + if (ts.isStringLiteral(command) && command.text === 'gh') { + const options = node.arguments[2].getText(source); + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + ghCalls.push({ + location: `${relative(API_ROOT, path)}:${line}`, + hidden: options.includes('withHiddenGhCliWindow') || options.includes('getGitHubExecOptions'), + }); + } + } + ts.forEachChild(node, visit); + } + + visit(source); + } + + assert.ok(ghCalls.length > 0, 'expected to find direct gh child-process calls'); + assert.deepEqual( + ghCalls.filter((call) => !call.hidden).map((call) => call.location), + [], + 'all direct gh child-process calls must use the shared hidden-window options', + ); + }); +}); From 9d21a0df8356533918f7106735ed2b6fb59f44cc Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sat, 11 Jul 2026 11:17:38 +0800 Subject: [PATCH 09/15] =?UTF-8?q?verdict(eval:a2a):=202026-07-11-eval-a2a-?= =?UTF-8?q?low-traffic-clean-keep-observe=20=E2=80=94=20keep=5Fobserve=20(?= =?UTF-8?q?#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-11 eval:a2a window is clean but low-traffic: C2 forced-pass and void-hold counters are 0/16, C1 zombie/cancel counters are 0, and grounding mismatch_sample_count is 0 across two stored grounding samples. The domain registry still has legacyScheduledTaskIds=[], so this is the ordinary daily trigger rather than a duplicate legacy scheduled task. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 11 +++ .../provenance.json | 19 +++++ .../snapshot.json | 82 +++++++++++++++++++ ...eval-a2a-low-traffic-clean-keep-observe.md | 39 +++++++++ 4 files changed, 151 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-11-eval-a2a-low-traffic-clean-keep-observe.md diff --git a/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/attribution.json new file mode 100644 index 0000000000..329415b841 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-11-eval-a2a-low-traffic-clean-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-11", + "generatedAt": "2026-07-11T03:14:24.367Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 5 components", + "evidence": "Checked components: L1, C1, C2, route-serial, grounding-phase-o. Friction metrics examined: c1.hold_zombie_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, grounding.budget_exhausted_total. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/provenance.json new file mode 100644 index 0000000000..203ea1e18e --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-11-eval-a2a-low-traffic-clean-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-11-eval-F167-runtime.yaml", + "sha256": "13bd70769d3a142fdad138296d30bbff2e60b43981ad5e43bd1c80e73d6cc294" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-11-eval-F167-attribution.yaml", + "sha256": "717830c400462712c7d9e4a995aacf3ca2f4e3310796f532c0131bc5ef51e5d7" + } + ], + "generatedAt": "2026-07-11T03:14:24.367Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot.json new file mode 100644 index 0000000000..aa6bf96220 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot.json @@ -0,0 +1,82 @@ +{ + "verdictId": "2026-07-11-eval-a2a-low-traffic-clean-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-11", + "featureId": "F167", + "generatedAt": "2026-07-11T03:14:24.366Z", + "window": { + "startMs": 1783693354481, + "endMs": 1783739419707, + "durationHours": 12.79589611111111 + }, + "counterWindow": { + "startMs": 1783692875048, + "endMs": 1783739664363, + "durationHours": 12.997031821955833 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0, + "c1.hold_replacement_count": 0 + }, + "frictionCounts": { + "c1.hold_zombie_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 16, + "c2.void_hold_checked": 16 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 0 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "line_start.detected": 7, + "inline_action.checked": 16 + }, + "frictionCounts": {} + }, + { + "id": "grounding-phase-o", + "name": "claim grounding (Phase O shadow)", + "confidence": "medium", + "activationCounts": { + "grounding.check_total": 1, + "grounding.verdict_total": 1, + "grounding.resolver_total": 0, + "grounding.cache_hit_total": 0, + "grounding.sample_count": 2, + "grounding.mismatch_sample_count": 0 + }, + "frictionCounts": { + "grounding.budget_exhausted_total": 0 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-11-eval-a2a-low-traffic-clean-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-11-eval-a2a-low-traffic-clean-keep-observe.md new file mode 100644 index 0000000000..a8b6e2305d --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-11-eval-a2a-low-traffic-clean-keep-observe.md @@ -0,0 +1,39 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-11-eval-a2a-low-traffic-clean-keep-observe +source_snapshot: "snapshot:bundle/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-11-eval-a2a-low-traffic-clean-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: The 2026-07-11 eval:a2a window is clean but low-traffic: C2 forced-pass and void-hold counters are 0/16, C1 zombie/cancel counters are 0, and grounding mismatch_sample_count is 0 across two stored grounding samples. The domain registry still has legacyScheduledTaskIds=[], so this is the ordinary daily trigger rather than a duplicate legacy scheduled task. +- Harness: F167/C2 (A2A Chain Quality runtime harness: exit-check / void-hold / grounding shadow telemetry) +- Owner ask: No code action from this packet. Continue ordinary eval:a2a monitoring; promote a separate F167 build/fix ask only if a higher-denominator window repeats void_hold drift above the 5% floor, verdict_without_pass returns, grounding mismatch_sample_count becomes positive with a recurring pattern, or sample coverage remains poor on recurring emissions. +- Re-eval: Keep daily eval active. Treat the regression as still watched until a high-denominator window remains below the 5% floor for C2 void_hold/verdict_without_pass and grounding mismatch_sample_count stays zero. at 2026-07-12T03:00:00.000Z + +Evidence: +- snapshot:bundle/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/snapshot +- attribution:bundle/2026-07-11-eval-a2a-low-traffic-clean-keep-observe/eval-F167-2026-07-11:no-finding +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:c1.hold_zombie_count +- metric:c1.hold_cancel_count +- metric:grounding.check_total +- metric:grounding.verdict_total +- metric:grounding.mismatch_sample_count +- metric:legacyScheduledTaskIds +- metadata:traceStoreStats/spanCount=81/counterWindowHours=13.00 +- metadata:groundingSamples/stored=2/mismatch=0 +- metadata:legacyScheduledTaskIds=0/legacyCleanup=disabled + +Counterarguments: +- 0/16 is clean but statistically weak; it does not erase the 7/3-7/8 monotonic void_hold drift noted in prior thread context. +- The latest committed baseline is 7/9 because 7/10 was interrupted, so this packet compares against the last durable artifact rather than a complete previous-day verdict. +- Grounding Phase O is healthy on mismatch count but under-exercised: one check counter and two insufficient stored samples are not enough to justify fail-closed escalation or closure. From 30f5ba1e24539f0b533d3502214bf5c6ee5a5288 Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sun, 12 Jul 2026 11:04:12 +0800 Subject: [PATCH 10/15] =?UTF-8?q?verdict(eval:friction):=202026-07-12-eval?= =?UTF-8?q?-friction-singleton-user-feedback-improved-baseline=20=E2=80=94?= =?UTF-8?q?=20keep=5Fobserve=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The every-3d friction window from 2026-07-09 03:00 UTC to 2026-07-12 03:00 UTC collapsed to one medium-severity actionable singleton, `text_frustration: 错了 什么情况`, with no reference-only eval-domain clusters and no long-tail spillover. Compared with the previous 72h window's 9 signals and 5 clusters, overall friction volume clearly improved even though one user-feedback incident remained. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 36 ++++++++ .../provenance.json | 15 +++ .../raw/rollup-report.json | 92 +++++++++++++++++++ .../snapshot.json | 26 ++++++ ...ngleton-user-feedback-improved-baseline.md | 31 +++++++ 5 files changed, 200 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/raw/rollup-report.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline.md diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/attribution.json b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/attribution.json new file mode 100644 index 0000000000..4907f9f384 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/attribution.json @@ -0,0 +1,36 @@ +{ + "verdictId": "2026-07-12-eval-friction-singleton-user-feedback-improved-baseline", + "featureId": "F245", + "evalSnapshotId": "eval-F245-2026-07-12", + "generatedAt": "2026-07-12T03:03:25.003Z", + "findings": [ + { + "id": "FR-2026-07-12-4e7626161f05", + "relatedFeature": "F245", + "frictionSignal": { + "type": "friction.cluster_4e7626161f05", + "severity": "medium", + "confidence": 0.7, + "detectedAt": "2026-07-12T03:03:25.003Z" + }, + "attribution": { + "primaryLayer": "needs_investigation", + "evidence": [ + { + "type": "counter", + "anchor": "friction-rollup/cluster_4e7626161f05", + "excerpt": "cluster 'text_frustration: 错了 什么情况' count=1 severity=medium sensorForms=[reason]" + } + ] + }, + "proposedAction": [ + { + "action": "triage-top-friction-cluster", + "target": "F245/friction-rollup", + "rationale": "Highest-ranked friction cluster in the window — eval cat assigns the 7-class root cause in the verdict before handoff." + } + ], + "status": "open" + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/provenance.json b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/provenance.json new file mode 100644 index 0000000000..ebb9971f53 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/provenance.json @@ -0,0 +1,15 @@ +{ + "verdictId": "2026-07-12-eval-friction-singleton-user-feedback-improved-baseline", + "rawInputs": [ + { + "path": "docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/raw/rollup-report.json", + "sha256": "127da312b1484ce1b22f1541389fc0c9cf80e2a41ccbcd403aa4d29075e620e7" + } + ], + "generatedAt": "2026-07-12T03:03:25.003Z", + "generator": { + "name": "eval-friction-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f245-friction-rollup-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/raw/rollup-report.json b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/raw/rollup-report.json new file mode 100644 index 0000000000..d036305a81 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/raw/rollup-report.json @@ -0,0 +1,92 @@ +{ + "verdictId": "2026-07-12-eval-friction-singleton-user-feedback-improved-baseline", + "selector": { + "kind": "friction-rollup-snapshot", + "windowStartMs": 1783566000000, + "windowEndMs": 1783825200000, + "topN": 10, + "tokenCap": 4000 + }, + "window": { + "sinceMs": 1783566000000, + "untilMs": 1783825200000 + }, + "signalCount": 1, + "clusterCount": 1, + "degraded": false, + "droppedChannels": [], + "report": { + "window": { + "sinceMs": 1783566000000, + "untilMs": 1783825200000 + }, + "generatedAt": "2026-07-12T03:03:25.003Z", + "topClusters": [ + { + "clusterId": "4e7626161f05", + "representative": "text_frustration: 错了 什么情况", + "channels": [ + "user-feedback" + ], + "count": 1, + "members": [ + { + "signalId": "user-feedback:fi_mrg2nhcf21wi0xzq", + "rawRef": "fi_mrg2nhcf21wi0xzq", + "channel": "user-feedback" + } + ], + "method": "rule", + "sensorForms": [ + "reason" + ], + "severity": "medium" + } + ], + "actionableCandidates": [ + { + "clusterId": "4e7626161f05", + "representative": "text_frustration: 错了 什么情况", + "channels": [ + "user-feedback" + ], + "count": 1, + "members": [ + { + "signalId": "user-feedback:fi_mrg2nhcf21wi0xzq", + "rawRef": "fi_mrg2nhcf21wi0xzq", + "channel": "user-feedback" + } + ], + "method": "rule", + "sensorForms": [ + "reason" + ], + "severity": "medium", + "actionability": "actionable_candidate", + "followupDraft": { + "clusterId": "4e7626161f05", + "title": "Investigate friction cluster: text_frustration: 错了 什么情况", + "summary": "text_frustration: 错了 什么情况", + "evidenceRefs": [ + "fi_mrg2nhcf21wi0xzq" + ], + "reportingMode": "final-only" + }, + "referenceOnlyEvidenceRefs": [] + } + ], + "referenceOnly": [], + "tailSummary": { + "clusterCount": 0, + "signalCount": 0, + "byChannel": {} + }, + "degraded": false, + "droppedChannels": [], + "tokenBudget": { + "cap": 4000, + "estimated": 296 + } + } +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot.json b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot.json new file mode 100644 index 0000000000..5f3fd5d4da --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot.json @@ -0,0 +1,26 @@ +{ + "verdictId": "2026-07-12-eval-friction-singleton-user-feedback-improved-baseline", + "evalSnapshotId": "eval-F245-2026-07-12", + "featureId": "F245", + "generatedAt": "2026-07-12T03:03:25.003Z", + "window": { + "startMs": 1783566000000, + "endMs": 1783825200000, + "durationHours": 72 + }, + "components": [ + { + "id": "friction-rollup", + "name": "friction rollup (Top-N + sensorForm)", + "confidence": "medium", + "activationCounts": {}, + "frictionCounts": { + "cluster_count": 1, + "top_cluster_count": 1, + "tail_cluster_count": 0, + "tail_signal_count": 0, + "cluster_4e7626161f05": 1 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline.md b/docs/harness-feedback/verdicts/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline.md new file mode 100644 index 0000000000..4df141e012 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline.md @@ -0,0 +1,31 @@ +--- +feature_ids: [F245] +topics: [harness-eval, eval-friction, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:friction +packet_id: 2026-07-12-eval-friction-singleton-user-feedback-improved-baseline +source_snapshot: "snapshot:bundle/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot" +--- + +# Live Verdict — 2026-07-12-eval-friction-singleton-user-feedback-improved-baseline + +- Verdict: `keep_observe` +- Phenomenon: The every-3d friction window from 2026-07-09 03:00 UTC to 2026-07-12 03:00 UTC collapsed to one medium-severity actionable singleton, `text_frustration: 错了 什么情况`, with no reference-only eval-domain clusters and no long-tail spillover. Compared with the previous 72h window's 9 signals and 5 clusters, overall friction volume clearly improved even though one user-feedback incident remained. +- Harness: F245/friction-rollup (friction rollup (Top-N + sensorForm)) +- Root cause: Most likely a transient `execution_gap`: the only surviving cluster came from a thread where a cat asserted the wrong API/root-cause diagnosis before checking the active runtime path, and the user explicitly pushed back with `错了 / 什么情况`. Confidence stays low because the signal is a singleton, the rollup is still degraded, and no second channel or recurrence confirmed a stable failure mode. (confidence low) +- Owner ask: Keep the every-3d friction rollup running and escalate only if this user-feedback pattern recurs, gains a second channel, or a fresh reference-only eval-domain cluster reappears in the next window. +- Re-eval: next eval at 2026-07-15T03:00:00.000Z + +Evidence: +- snapshot:bundle/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/snapshot +- attribution:bundle/2026-07-12-eval-friction-singleton-user-feedback-improved-baseline/FR-2026-07-12-4e7626161f05 +- metric:friction-rollup.cluster_count +- metric:friction-rollup.top_cluster_count +- metric:friction-rollup.tail_signal_count +- metric:friction-rollup.cluster_4e7626161f05 + +Counterarguments: +- Even as a singleton, direct user pushback that a diagnosis was wrong can justify immediate owner attention instead of another observation cycle. +- Because the current rollup remained degraded, related signals may have been missed and made a broader pattern look smaller than it is. +- The disappearance of reference-only clusters may reflect quiet source-domain activity rather than a real improvement in friction quality. \ No newline at end of file From 3157ca4146a725405ec0c1eab8e16dcd6c1c1cf3 Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sun, 12 Jul 2026 11:04:21 +0800 Subject: [PATCH 11/15] =?UTF-8?q?verdict(eval:a2a):=202026-07-12-eval-a2a-?= =?UTF-8?q?denominator-recovered-clean-keep-observe=20=E2=80=94=20keep=5Fo?= =?UTF-8?q?bserve=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-12 eval:a2a window is clean with a recovered C2 denominator: forced-pass is 0/108, void-hold is 0/109, C1 zombie/cancel counters are 0, and grounding mismatch_sample_count is 0 across four stored grounding samples. The domain registry still has legacyScheduledTaskIds=[], so this daily run is not a duplicate legacy trigger. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 11 +++ .../provenance.json | 19 +++++ .../snapshot.json | 85 +++++++++++++++++++ ...enominator-recovered-clean-keep-observe.md | 39 +++++++++ 4 files changed, 154 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe.md diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/attribution.json new file mode 100644 index 0000000000..f41d27c4eb --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-12", + "generatedAt": "2026-07-12T03:02:17.575Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 5 components", + "evidence": "Checked components: L1, C1, C2, route-serial, grounding-phase-o. Friction metrics examined: c1.hold_zombie_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.feedback_written, grounding.budget_exhausted_total. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/provenance.json new file mode 100644 index 0000000000..dff627433b --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-12-eval-F167-runtime.yaml", + "sha256": "f27fee15da592959c211471f118db3ad29804ceed7f8d9363d07e72d3bccd441" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-12-eval-F167-attribution.yaml", + "sha256": "1f9218cd63ab45006242f15e95a296dd3d2dd53319f76609af9ed0d7b2ee48df" + } + ], + "generatedAt": "2026-07-12T03:02:17.575Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot.json new file mode 100644 index 0000000000..4c2c6f884e --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot.json @@ -0,0 +1,85 @@ +{ + "verdictId": "2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-12", + "featureId": "F167", + "generatedAt": "2026-07-12T03:02:17.574Z", + "window": { + "startMs": 1783739935067, + "endMs": 1783825316085, + "durationHours": 23.716949444444445 + }, + "counterWindow": { + "startMs": 1783781681401, + "endMs": 1783825337571, + "durationHours": 12.126713968345 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0, + "c1.hold_replacement_count": 0 + }, + "frictionCounts": { + "c1.hold_zombie_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 108, + "c2.void_hold_checked": 109 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 0 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 109, + "line_start.detected": 55, + "inline_action.detected": 1 + }, + "frictionCounts": { + "inline_action.feedback_written": 1 + } + }, + { + "id": "grounding-phase-o", + "name": "claim grounding (Phase O shadow)", + "confidence": "medium", + "activationCounts": { + "grounding.check_total": 2, + "grounding.verdict_total": 2, + "grounding.resolver_total": 0, + "grounding.cache_hit_total": 0, + "grounding.sample_count": 4, + "grounding.mismatch_sample_count": 0 + }, + "frictionCounts": { + "grounding.budget_exhausted_total": 0 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe.md new file mode 100644 index 0000000000..3b0237c9e7 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe.md @@ -0,0 +1,39 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe +source_snapshot: "snapshot:bundle/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: The 2026-07-12 eval:a2a window is clean with a recovered C2 denominator: forced-pass is 0/108, void-hold is 0/109, C1 zombie/cancel counters are 0, and grounding mismatch_sample_count is 0 across four stored grounding samples. The domain registry still has legacyScheduledTaskIds=[], so this daily run is not a duplicate legacy trigger. +- Harness: F167/C2 (A2A Chain Quality runtime harness: exit-check / void-hold / grounding shadow telemetry) +- Owner ask: No code action from this packet. Continue ordinary eval:a2a monitoring; promote a separate F167 build/fix ask only if a high-denominator window repeats void_hold drift above the 5% floor, verdict_without_pass returns, grounding mismatch_sample_count becomes positive with a recurring pattern, or sample coverage remains poor on recurring emissions. +- Re-eval: Keep daily eval active. Treat the regression as ordinary monitored until another high-denominator clean window keeps C2 void_hold/verdict_without_pass below the 5% floor and grounding mismatch_sample_count stays zero. at 2026-07-13T03:00:00.000Z + +Evidence: +- snapshot:bundle/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/snapshot +- attribution:bundle/2026-07-12-eval-a2a-denominator-recovered-clean-keep-observe/eval-F167-2026-07-12:no-finding +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:c1.hold_zombie_count +- metric:c1.hold_cancel_count +- metric:grounding.check_total +- metric:grounding.verdict_total +- metric:grounding.mismatch_sample_count +- metric:legacyScheduledTaskIds +- metadata:traceStoreStats/spanCount=534/counterWindowHours=12.13 +- metadata:groundingSamples/stored=4/mismatch=0 +- metadata:legacyScheduledTaskIds=0/legacyCleanup=disabled + +Counterarguments: +- The day-over-day friction counts are flat at zero, so the improvement is mainly denominator/reliability rather than a lower count. +- The 7/11 durable packet is not present in this develop checkout, but GitHub shows PR #68 merged to main; this packet uses that as the prior durable thread baseline while publishing against origin/main. +- Grounding Phase O has no mismatches, but four insufficient samples indicate under-exercised resolver coverage rather than a reason to fail closed. From 2057de45e3a5e2baad5e172599ef333ab62ff7ea Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sun, 12 Jul 2026 11:08:32 +0800 Subject: [PATCH 12/15] =?UTF-8?q?verdict(eval:qc):=202026-07-12-eval-qc-ze?= =?UTF-8?q?ro-baseline-w28=20=E2=80=94=20keep=5Fobserve=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QC pipeline metrics remain in zero-baseline state — no review telemetry events collected during the Jul 5–12 window. Phase C bootstrap: the qc-metrics-provider returns zeroes for all 4 metrics (finding yield, false positive rate, reviewer delta, post-merge bug rate) because no live data source is wired yet. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 11 ++++++ .../provenance.json | 15 ++++++++ .../snapshot.json | 27 ++++++++++++++ .../2026-07-12-eval-qc-zero-baseline-w28.md | 36 +++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-12-eval-qc-zero-baseline-w28.md diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/attribution.json b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/attribution.json new file mode 100644 index 0000000000..745601fec8 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-12-eval-qc-zero-baseline-w28", + "featureId": "F253", + "evalSnapshotId": "qc-snapshot-2026-07-12-eval-qc-zero-baseline-w28", + "generatedAt": "2026-07-12T03:07:54.524Z", + "findings": [], + "noFindingRecord": { + "reason": "Zero-baseline bootstrap (Phase C) — no live QC data sources wired", + "evidence": "All QC metrics are zero (prCount=0). Eval:qc data pipeline not yet active." + } +} \ No newline at end of file diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/provenance.json b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/provenance.json new file mode 100644 index 0000000000..244c54d100 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/provenance.json @@ -0,0 +1,15 @@ +{ + "verdictId": "2026-07-12-eval-qc-zero-baseline-w28", + "rawInputs": [ + { + "path": "bundles/2026-07-12-eval-qc-zero-baseline-w28/snapshot.json", + "sha256": "4235c7eb9bb3e203ad46425451f5e2766546a24ede6f831759e96d4605a36c9d" + } + ], + "generatedAt": "2026-07-12T03:07:54.524Z", + "generator": { + "name": "qc-generator-adapter", + "version": "1.0.0" + }, + "sanitizeRulesVersion": "1.0.0" +} \ No newline at end of file diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/snapshot.json b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/snapshot.json new file mode 100644 index 0000000000..25773b06c1 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-qc-zero-baseline-w28/snapshot.json @@ -0,0 +1,27 @@ +{ + "verdictId": "2026-07-12-eval-qc-zero-baseline-w28", + "evalSnapshotId": "qc-snapshot-2026-07-12-eval-qc-zero-baseline-w28", + "featureId": "F253", + "generatedAt": "2026-07-12T03:07:54.524Z", + "window": { + "startMs": 1783209600000, + "endMs": 1783814400000, + "durationHours": 168 + }, + "components": [ + { + "componentId": "qc-pipeline", + "componentName": "QC Pipeline", + "activationCounts": { + "finding_yield": 0, + "reviewer_delta": 0, + "pr_count": 0 + }, + "frictionCounts": { + "false_positive_rate": 0, + "post_merge_bug_rate": 0 + }, + "confidence": "no-data" + } + ] +} \ No newline at end of file diff --git a/docs/harness-feedback/verdicts/2026-07-12-eval-qc-zero-baseline-w28.md b/docs/harness-feedback/verdicts/2026-07-12-eval-qc-zero-baseline-w28.md new file mode 100644 index 0000000000..e6010cc717 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-12-eval-qc-zero-baseline-w28.md @@ -0,0 +1,36 @@ +--- +feature_ids: [F253] +topics: [harness-eval, eval-qc, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:qc +packet_id: 2026-07-12-eval-qc-zero-baseline-w28 +source_snapshot: "snapshot:bundle/2026-07-12-eval-qc-zero-baseline-w28/snapshot" +--- + +# eval:qc Verdict — 2026-07-12-eval-qc-zero-baseline-w28 + +- Verdict: `keep_observe` +- Phenomenon: QC pipeline metrics remain in zero-baseline state — no review telemetry events collected during the Jul 5–12 window. Phase C bootstrap: the qc-metrics-provider returns zeroes for all 4 metrics (finding yield, false positive rate, reviewer delta, post-merge bug rate) because no live data source is wired yet. +- Harness: F253/qc-pipeline (QC Review Loop) +- Owner ask: No action required. Continue observing until a future phase wires live review telemetry events into the QC metrics provider. +- Re-eval: next eval at 2026-07-19T03:00:00.000Z + +Evidence: +- snapshot:bundle/2026-07-12-eval-qc-zero-baseline-w28/snapshot +- attribution:bundle/2026-07-12-eval-qc-zero-baseline-w28/qc-snapshot-2026-07-12-eval-qc-zero-baseline-w28:no-finding + +**Window**: 7 days | **PRs analyzed**: 0 + +## Metrics + +| Metric | Value | +|--------|-------| +| Finding Yield (avg/review) | 0 | +| False Positive Rate | 0 | +| Reviewer Delta | 0 | +| Post-Merge Bug Rate | 0 | + +## Notes + +No PR data available in this window. Zero-baseline snapshot (Phase C bootstrap — live data sources not yet wired). From bd368638326f64782216ecf71d0853c1fd2bd5db Mon Sep 17 00:00:00 2001 From: bouillipx Date: Sun, 12 Jul 2026 11:18:11 +0800 Subject: [PATCH 13/15] =?UTF-8?q?verdict(eval:anchor-first):=202026-07-12-?= =?UTF-8?q?eval-anchor-first-c1-keep-observe=20=E2=80=94=20keep=5Fobserve?= =?UTF-8?q?=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latest 24h anchor-first window appears low-volume and concentrated in thread-context preview traffic, while independent blindness evidence is absent because eval:task-outcome has not yet produced published verdict trends. Current evidence is enough to watch adoption and open-rate detail, but not enough to justify sunset or a corrective fix. [published via cat_cafe_publish_verdict MCP] --- .../attribution.json | 47 ++++++++++++++++++ .../provenance.json | 15 ++++++ .../raw/rollup.json | 49 +++++++++++++++++++ .../snapshot.json | 39 +++++++++++++++ ...07-12-eval-anchor-first-c1-keep-observe.md | 44 +++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/attribution.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/provenance.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/raw/rollup.json create mode 100644 docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot.json create mode 100644 docs/harness-feedback/verdicts/2026-07-12-eval-anchor-first-c1-keep-observe.md diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/attribution.json new file mode 100644 index 0000000000..6c813e1e23 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/attribution.json @@ -0,0 +1,47 @@ +{ + "verdictId": "2026-07-12-eval-anchor-first-c1-keep-observe", + "featureId": "F236", + "evalSnapshotId": "eval-F236-2026-07-12", + "generatedAt": "2026-07-12T03:16:06.684Z", + "findings": [ + { + "id": "AF-2026-07-12-thread-context", + "relatedFeature": "F236", + "sunsetSignals": { + "anchorTax": false, + "highOpenRate": false, + "netNegative": false + }, + "frictionSignal": { + "type": "anchor.open_rate.thread-context", + "severity": "low", + "confidence": 0.8, + "detectedAt": "2026-07-12T03:16:06.684Z" + }, + "attribution": { + "primaryLayer": "needs_investigation", + "evidence": [ + { + "type": "counter", + "anchor": "anchor-telemetry-rollup/thread-context_drilled_unique_items", + "excerpt": "thread-context: 1/110 items drilled (0.9% open rate), charsSaved=280650, drillChars=1453, netBenefit=279197" + } + ] + }, + "proposedAction": [ + { + "action": "keep-observe", + "target": "anchor-telemetry-rollup/thread-context", + "rationale": "Open rate 0.9%: 1/110 items drilled, net benefit 279197 chars" + } + ] + } + ], + "sunsetAssessment": { + "toolCount": 1, + "toolsWithAnchorTax": 0, + "toolsNetNegative": 0, + "toolsHighOpenRate": 0, + "lowSampleToolCount": 0 + } +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/provenance.json new file mode 100644 index 0000000000..8fbb77a1b3 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/provenance.json @@ -0,0 +1,15 @@ +{ + "verdictId": "2026-07-12-eval-anchor-first-c1-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/raw/rollup.json", + "sha256": "e6d7fd49e6e2c1b2858988d69156324fd70a93b4233ed0b8e0d6afe3d19842d8" + } + ], + "generatedAt": "2026-07-12T03:16:06.684Z", + "generator": { + "name": "eval-anchor-first-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f236-anchor-telemetry-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/raw/rollup.json b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/raw/rollup.json new file mode 100644 index 0000000000..409dcc2f3e --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/raw/rollup.json @@ -0,0 +1,49 @@ +{ + "verdictId": "2026-07-12-eval-anchor-first-c1-keep-observe", + "selector": { + "kind": "anchor-telemetry-snapshot", + "windowStartMs": 1783739721102, + "windowEndMs": 1783826121102 + }, + "rollup": { + "perTool": { + "thread-context": { + "previewResponses": 16, + "previewedItems": 110, + "drills": 1, + "drilledUniqueItems": 1, + "openRateByItem": 0.00909090909090909, + "returnedChars": 35478, + "originalChars": 316128, + "charsSaved": 280650, + "drillChars": 1453, + "netBenefit": 279197 + } + }, + "orphanDrills": 0, + "adoption": { + "explicitAnchorCalls": 2, + "explicitFullCalls": 3, + "defaultAnchorCalls": 14, + "defaultFullCalls": 0, + "legacyEquivalentAnchorCalls": 0, + "legacyEquivalentFullCalls": 1, + "unknownModeCalls": 0, + "uniqueCatsExplicitAnchor": 1 + }, + "track1Snapshot": { + "returnedByTool": { + "thread-context": 16 + }, + "returnedCharsByTool": { + "thread-context": 85572 + }, + "drillByTool": { + "get-message": 1 + }, + "drillCharsByTool": { + "get-message": 1453 + } + } + } +} diff --git a/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot.json new file mode 100644 index 0000000000..af84fa5a6b --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot.json @@ -0,0 +1,39 @@ +{ + "verdictId": "2026-07-12-eval-anchor-first-c1-keep-observe", + "evalSnapshotId": "eval-F236-2026-07-12", + "featureId": "F236", + "generatedAt": "2026-07-12T03:16:06.684Z", + "window": { + "startMs": 1783739721102, + "endMs": 1783826121102, + "durationHours": 24 + }, + "components": [ + { + "id": "anchor-telemetry-rollup", + "name": "anchor-first preview/drill open-rate rollup", + "confidence": "medium", + "activationCounts": { + "orphan_drills": 0, + "adoption_explicit_anchor_calls": 2, + "adoption_explicit_full_calls": 3, + "adoption_default_anchor_calls": 14, + "adoption_default_full_calls": 0, + "adoption_legacy_equivalent_anchor_calls": 0, + "adoption_legacy_equivalent_full_calls": 1, + "adoption_unique_cats_explicit_anchor": 1, + "adoption_unknown_mode_calls": 0, + "thread-context_preview_responses": 16, + "thread-context_previewed_items": 110, + "thread-context_drills": 1, + "thread-context_drilled_unique_items": 1, + "thread-context_returned_chars": 35478, + "thread-context_original_chars": 316128, + "thread-context_chars_saved": 280650, + "thread-context_drill_chars": 1453, + "thread-context_net_benefit": 279197 + }, + "frictionCounts": {} + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-07-12-eval-anchor-first-c1-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-12-eval-anchor-first-c1-keep-observe.md new file mode 100644 index 0000000000..93d837ce2c --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-12-eval-anchor-first-c1-keep-observe.md @@ -0,0 +1,44 @@ +--- +feature_ids: [F192, F236] +topics: [harness-eval, eval-anchor-first, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:anchor-first +packet_id: 2026-07-12-eval-anchor-first-c1-keep-observe +source_snapshot: "snapshot:bundle/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-12-eval-anchor-first-c1-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: The latest 24h anchor-first window appears low-volume and concentrated in thread-context preview traffic, while independent blindness evidence is absent because eval:task-outcome has not yet produced published verdict trends. Current evidence is enough to watch adoption and open-rate detail, but not enough to justify sunset or a corrective fix. +- Harness: F236/anchor-telemetry-rollup (anchor-first preview/drill open-rate rollup) +- Owner ask: Keep F236 in observe mode, gather another full 24h anchor-telemetry window, and wait for eval:task-outcome trend evidence before considering fix or sunset. +- Re-eval: Re-evaluate when the next 24h rollup either shows a tool with enough preview volume to assess sunset signals confidently or eval:task-outcome publishes trend evidence that can confirm or reject blindness. at 2026-07-19T03:15:21.102Z + +Sunset Signal Assessment: +- thread-context: HEALTHY (openRate=0.9%, netBenefit=279197) + +Open-Rate Detail: +- thread-context: 0.9% open rate (1/110 items), charsSaved=280650, drillChars=1453, netBenefit=279197 +- Orphan drills: 0 + +Adoption Detail: +- explicitAnchorCalls=2; explicitFullCalls=3; uniqueCatsExplicitAnchor=1 +- defaultAnchorCalls=14; defaultFullCalls=0 +- legacyEquivalentAnchorCalls=0; legacyEquivalentFullCalls=1 +- unknownModeCalls=0 + +Evidence: +- snapshot:bundle/2026-07-12-eval-anchor-first-c1-keep-observe/snapshot +- attribution:bundle/2026-07-12-eval-anchor-first-c1-keep-observe/AF-2026-07-12-thread-context +- metric:prometheus:cat_cafe_anchor_returned_count_total{anchor_tool="thread-context"} +- metric:prometheus:cat_cafe_anchor_full_drill_count_total{anchor_tool="get-message"} +- metric:sqlite:task_outcome_episodes.with_verdict=0 +- data/transcripts/threads/thread_eval_anchor_first/gpt52/sessions/bb6104ac-0a88-4f75-81cd-b14e856c39db/events.live.jsonl +- task-outcome-episodes.sqlite + +Counterarguments: +- The live rollup could still show a high open-rate or negative net benefit on a low-visibility tool even though Track-1 counters look healthy at the aggregate level. +- Because task-outcome evidence is currently missing, this verdict may be underreacting to a real but unmeasured blindness effect. +- If the current process lifetime is materially shorter than 24h, the window may under-sample adoption and make the rollout look healthier than it is. From 2bfd3c7a589a601d6e007814f5d3c849e7472854 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.6" Date: Sun, 12 Jul 2026 23:51:27 +0800 Subject: [PATCH 14/15] feat(sync): auto-resolve merge conflicts in daily upstream sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two merge points now handle conflicts automatically instead of failing: 1. upstream→main: conflicts resolve with -X theirs (upstream wins). main's job is to track upstream; fork-specific code belongs on develop. The gate step still validates the result before pushing. 2. main→develop: conflicts resolve with -X ours (develop wins). develop is the active fork branch; upstream changes that conflict with fork code should not silently overwrite them. Both try a clean merge first — the -X strategy only kicks in when there are actual conflicts, logged via GitHub Actions ::warning::. This prevents the sync workflow from failing for weeks when branches diverge (as happened 2026-07-08 through 2026-07-12 with 10+ failures). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/sync-upstream-main.yml | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-upstream-main.yml b/.github/workflows/sync-upstream-main.yml index 50941a08a1..9bf0ce4f83 100644 --- a/.github/workflows/sync-upstream-main.yml +++ b/.github/workflows/sync-upstream-main.yml @@ -56,13 +56,26 @@ jobs: - name: Merge upstream into main id: main + # Auto-conflict resolution: when upstream/main conflicts with fork/main, + # upstream wins (-X theirs). fork/main's job is to track upstream; any + # fork-specific code belongs on develop. Verdict/eval commits on main + # add new files and rarely conflict with upstream code changes. + # The gate step below still validates the merged result before pushing. run: | set -euo pipefail git switch -C main origin/main git reset --hard origin/main before="$(git rev-parse HEAD)" - git merge --no-edit upstream/main + + if git merge --no-edit upstream/main; then + echo "Clean merge (no conflicts)" + else + echo "::warning::upstream→main merge conflict detected — auto-resolving (upstream wins: -X theirs)" + git merge --abort + git merge -X theirs --no-edit upstream/main + fi + after="$(git rev-parse HEAD)" if [ "$before" = "$after" ]; then @@ -123,6 +136,11 @@ jobs: - name: Prepare develop sync branch id: develop + # Auto-conflict resolution: when main conflicts with develop, develop + # wins (-X ours). develop is the active fork branch with feature work; + # upstream changes that conflict with fork code should not silently + # overwrite them. Non-conflicting upstream changes still merge in + # normally. The PR gives reviewers a chance to inspect the result. run: | set -euo pipefail git switch -C "$SYNC_BRANCH" origin/develop @@ -133,7 +151,13 @@ jobs: exit 0 fi - git merge --no-edit main + if git merge --no-edit main; then + echo "Clean merge (no conflicts)" + else + echo "::warning::main→develop merge conflict detected — auto-resolving (develop wins: -X ours)" + git merge --abort + git merge -X ours --no-edit main + fi if git diff --quiet origin/develop HEAD; then echo "needs_pr=false" >> "$GITHUB_OUTPUT" From 6ccf14bc7d3ee04e2a932677ecf3bc4fb0e0921d Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Mon, 13 Jul 2026 20:23:41 +0800 Subject: [PATCH 15/15] fix(sync): resolve CiCdRouter message builder conflict Why: PR #73 merges origin/main's extracted CI message builders with develop's tracking-instruction head guard. Keeping both the import and the old inline builder redeclared buildCiMessageContent and broke TypeScript, while dropping the guard would reintroduce stale instruction delivery. Move the head-bound instruction check into ci-message-content.ts and keep CiCdRouter as the re-exporting integration point. --- .../src/infrastructure/email/CiCdRouter.ts | 48 ------------------- .../email/ci-message-content.ts | 19 +++++++- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/packages/api/src/infrastructure/email/CiCdRouter.ts b/packages/api/src/infrastructure/email/CiCdRouter.ts index 84492642fc..8afe9a9a56 100644 --- a/packages/api/src/infrastructure/email/CiCdRouter.ts +++ b/packages/api/src/infrastructure/email/CiCdRouter.ts @@ -363,51 +363,3 @@ export class CiCdRouter { }; } } - -export function buildCiMessageContent( - poll: CiPollResult, - trackingInstructions?: string, - trackingInstructionsHeadSha?: string, -): string { - const bucketEmoji = poll.aggregateBucket === 'pass' ? '✅' : '❌'; - const bucketLabel = poll.aggregateBucket === 'pass' ? 'CI 通过' : 'CI 失败'; - - const lines: string[] = [ - `${bucketEmoji} **${bucketLabel}**`, - '', - `PR #${poll.prNumber} (${poll.repoFullName})`, - `Commit: \`${poll.headSha.slice(0, 7)}\``, - ]; - - const failedChecks = poll.checks.filter((c) => c.bucket === 'fail'); - if (failedChecks.length > 0) { - lines.push('', `--- 失败的检查 (${failedChecks.length}) ---`); - for (const check of failedChecks) { - const linkPart = check.link ? ` [查看](${check.link})` : ''; - const descPart = check.description ? ` — ${check.description.slice(0, 120)}` : ''; - lines.push(`❌ **${check.name}**${descPart}${linkPart}`); - } - } - - if (poll.aggregateBucket === 'fail') { - lines.push('', '请检查 CI 失败原因并修复。'); - } - - // F202 Phase 2C (AC-C2): append user-provided tracking instructions - if (shouldAppendTrackingInstructions(trackingInstructions, poll.headSha, trackingInstructionsHeadSha)) { - lines.push('', '📌 **Tracking Instructions**', trackingInstructions); - } - - return lines.join('\n'); -} - -function shouldAppendTrackingInstructions( - trackingInstructions: string | undefined, - currentHeadSha: string | undefined, - instructionsHeadSha: string | undefined, -): trackingInstructions is string { - if (!trackingInstructions) return false; - if (!instructionsHeadSha) return true; - if (!currentHeadSha) return false; - return instructionsHeadSha === currentHeadSha; -} diff --git a/packages/api/src/infrastructure/email/ci-message-content.ts b/packages/api/src/infrastructure/email/ci-message-content.ts index bb322ff4bc..164120e5c3 100644 --- a/packages/api/src/infrastructure/email/ci-message-content.ts +++ b/packages/api/src/infrastructure/email/ci-message-content.ts @@ -4,7 +4,11 @@ */ import type { CiPollResult } from './CiCdRouter.js'; -export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: string): string { +export function buildCiMessageContent( + poll: CiPollResult, + trackingInstructions?: string, + trackingInstructionsHeadSha?: string, +): string { const bucketEmoji = poll.aggregateBucket === 'pass' ? '✅' : '❌'; const bucketLabel = poll.aggregateBucket === 'pass' ? 'CI 通过' : 'CI 失败'; @@ -30,13 +34,24 @@ export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: } // F202 Phase 2C (AC-C2): append user-provided tracking instructions - if (trackingInstructions) { + if (shouldAppendTrackingInstructions(trackingInstructions, poll.headSha, trackingInstructionsHeadSha)) { lines.push('', '📌 **Tracking Instructions**', trackingInstructions); } return lines.join('\n'); } +function shouldAppendTrackingInstructions( + trackingInstructions: string | undefined, + currentHeadSha: string | undefined, + instructionsHeadSha: string | undefined, +): trackingInstructions is string { + if (!trackingInstructions) return false; + if (!instructionsHeadSha) return true; + if (!currentHeadSha) return false; + return instructionsHeadSha === currentHeadSha; +} + /** Terminal lifecycle (merged/closed) notification. */ export function buildLifecycleMessageContent( poll: Pick,