Merge remote-tracking branch 'origin/master' into xtr/identified-immutable-messages

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	packages/core/session/README.i18n.yaml
#	packages/session-title/session-title/tests/persistence.spec.ts
This commit is contained in:
_Kerman
2026-07-28 15:45:53 +08:00
174 changed files with 1994 additions and 1274 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 144616248d4e811b112f4c556502a960e681950b
README.zh.md: 036434ea6f10156a565734ac4da40f447e38ebb8
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397
README.zh.md: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe

View File

@@ -29,7 +29,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note.
Hook provenance records must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`Stop`) satisfy that owner-defined relation by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record; allowed context is instead evidenced by its sourced `user/message` — see the hooks Agent Note.
## Model Experience

View File

@@ -29,7 +29,7 @@ Claude CodeCodex hook 协议格式的**共享核心**。它不是 cordis 插
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp``hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,由 `appendHookResult` 拥有决策规则。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500为空时省略
与每个事件一样,它们必须位于开启轮次内。轮次中点(`PreToolUse``PostToolUse``Stop`)按构造位于 loop 的开启轮次中`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有方定义的关系`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
## 模型体验

View File

@@ -17,6 +17,11 @@ interface HookTransition {
delta: 1 | -1
}
interface HookTrace {
openTurn: number | null
pending: Map<string, number>
}
/** Correlation key shared by an invoked/result pair. */
function hookKey(data: { turn: number; point: string; handlerId: string }): string {
return `${data.turn}\0${data.point}\0${data.handlerId}`
@@ -24,10 +29,15 @@ function hookKey(data: { turn: number; point: string; handlerId: string }): stri
/** Validate one hook event against committed pending invocations. */
function validateHookEvent(
pending: ReadonlyMap<string, number>,
trace: HookTrace,
event: SessionEvent,
fail: InvariantFailure,
): HookTransition | undefined {
if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return undefined
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`)
if (event.data.turn !== trace.openTurn) {
fail(`${event.type} names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
}
if (event.type === 'hook/invoked') {
if (event.data.point.length === 0 || event.data.handlerId.length === 0) {
fail('hook/invoked point and handlerId must be non-empty')
@@ -38,9 +48,8 @@ function validateHookEvent(
}
return { key: hookKey(event.data), delta: 1 }
}
if (event.type !== 'hook/result') return undefined
const key = hookKey(event.data)
if ((pending.get(key) ?? 0) === 0) {
if ((trace.pending.get(key) ?? 0) === 0) {
fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`)
}
if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) {
@@ -60,28 +69,39 @@ function applyHookTransition(pending: Map<string, number>, transition: HookTrans
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
/* jscpd:ignore-start */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, Map<string, number>>()
const traces = new WeakMap<Session, HookTrace>()
const staged = new WeakMap<SessionEvent, { session: Session; transition: HookTransition }>()
const seed = (session: Session): Map<string, number> => {
const pending = new Map<string, number>()
traces.set(session, pending)
const seed = (session: Session): HookTrace => {
const trace: HookTrace = { openTurn: null, pending: new Map() }
traces.set(session, trace)
for (const event of session.events) {
const transition = validateHookEvent(pending, event, fail)
if (transition !== undefined) applyHookTransition(pending, transition)
if (event.type === 'turn/start') trace.openTurn = event.data.turn
else if (event.type === 'turn/end') trace.openTurn = null
const transition = validateHookEvent(trace, event, fail)
if (transition !== undefined) applyHookTransition(trace.pending, transition)
}
return pending
return trace
}
const traceFor = (session: Session): Map<string, number> => traces.get(session) ?? seed(session)
const traceFor = (session: Session): HookTrace => traces.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => {
const trace = traceFor(session)
if (event.type === 'turn/start') {
trace.openTurn = event.data.turn
return
}
if (event.type === 'turn/end') {
trace.openTurn = null
return
}
if (event.type !== 'hook/invoked' && event.type !== 'hook/result') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every hook provenance event */
if (candidate === undefined || candidate.session !== session) return fail('hook event published without pre-commit validation')
staged.delete(event)
applyHookTransition(traceFor(session), candidate.transition)
applyHookTransition(trace.pending, candidate.transition)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return

View File

@@ -29,12 +29,18 @@ const result = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
function startTurn(session: Session, turn = 1): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
}
describe('hook-protocol invariants', () => {
it('pairs serial and repeated handler invocations', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
startTurn(session)
session.append('hook/invoked', invoked())
session.append('hook/invoked', invoked())
session.append('step/start', { turn: 1, step: 1 })
session.append('hook/result', result())
session.append('hook/result', result())
})
@@ -56,26 +62,52 @@ describe('hook-protocol invariants', () => {
const session = new Session(SessionId('bare-hook-session'))
expect(() => {
ctx.emit('session/event', session, {
type: 'hook/invoked', seq: 0, time: 0, data: invoked(),
type: 'turn/start', seq: 0, time: 0,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
})
ctx.emit('session/event', session, {
type: 'hook/result', seq: 1, time: 1, data: result(),
type: 'hook/invoked', seq: 1, time: 1, data: invoked(),
})
ctx.emit('session/event', session, {
type: 'hook/result', seq: 2, time: 2, data: result(),
})
}).not.toThrow()
})
it('rejects hook events outside or for a different open turn', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
expect(() => session.append('hook/invoked', invoked())).toThrow(/outside any open turn/)
startTurn(session)
expect(() => session.append('hook/invoked', invoked({ turn: 2 }))).toThrow(/but open turn is 1/)
})
it('rejects an unenclosed hook event when replaying an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
startTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('hook/invoked', invoked())
await ctx.plugin(InvariantService)
await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})
it.each([
[invoked({ point: '' }), /point and handlerId must be non-empty/],
[invoked({ handlerId: '' }), /point and handlerId must be non-empty/],
[invoked({ dialect: 'other' }), /unknown dialect/],
])('rejects malformed hook invocation %#', async (data, message) => {
const ctx = await setup()
expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message)
const session = ctx.sessions.create()
startTurn(session)
expect(() => session.append('hook/invoked', data as never)).toThrow(message)
})
it('rejects unmatched and malformed results', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
startTurn(session)
expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/)
session.append('hook/invoked', invoked())
expect(() => session.append('hook/result', result({ durationMs: -1 })))