@principles/codex-adapter 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export declare const CODEX_EVENT_PRE_TOOL_USE = "PreToolUse";
3
3
  export declare const CODEX_EVENT_POST_TOOL_USE = "PostToolUse";
4
4
  export declare const CODEX_EVENT_USER_PROMPT_SUBMIT = "UserPromptSubmit";
5
5
  export declare const CODEX_EVENT_SESSION_START = "SessionStart";
6
+ export declare const CODEX_EVENT_STOP = "Stop";
6
7
  export declare const CODEX_EVENT_SESSION_END = "SessionEnd";
7
8
  export declare class CodexDecoderError extends Error {
8
9
  readonly reason: string;
@@ -1,13 +1,22 @@
1
+ import { CODEX_INGESTION_MIN_VERSION } from '../ingestion/codex-version.js';
2
+ // Two contract baselines coexist deliberately (G1 probe report §9): the
3
+ // four pre-ingestion events pin their payload contract to codex-cli 0.147.0,
4
+ // while the Stop / ingestion-side fields follow the ingestion baseline
5
+ // (CODEX_INGESTION_MIN_VERSION = 0.148.0). Each message names the baseline
6
+ // that defines it — never mix them.
7
+ const FOUR_EVENT_CONTRACT_VERSION = '0.147.0';
1
8
  export const CODEX_EVENT_PRE_TOOL_USE = 'PreToolUse';
2
9
  export const CODEX_EVENT_POST_TOOL_USE = 'PostToolUse';
3
10
  export const CODEX_EVENT_USER_PROMPT_SUBMIT = 'UserPromptSubmit';
4
11
  export const CODEX_EVENT_SESSION_START = 'SessionStart';
12
+ export const CODEX_EVENT_STOP = 'Stop';
5
13
  export const CODEX_EVENT_SESSION_END = 'SessionEnd';
6
14
  const EVENT_KINDS = new Map([
7
15
  [CODEX_EVENT_PRE_TOOL_USE, 'before_tool_call'],
8
16
  [CODEX_EVENT_POST_TOOL_USE, 'after_tool_call'],
9
17
  [CODEX_EVENT_USER_PROMPT_SUBMIT, 'before_prompt_build'],
10
18
  [CODEX_EVENT_SESSION_START, 'session_start'],
19
+ [CODEX_EVENT_STOP, 'turn_complete'],
11
20
  ]);
12
21
  export class CodexDecoderError extends Error {
13
22
  reason;
@@ -28,13 +37,13 @@ function own(value, key) {
28
37
  function requiredString(value, key) {
29
38
  const candidate = own(value, key);
30
39
  if (typeof candidate !== 'string' || candidate.trim().length === 0) {
31
- throw new CodexDecoderError(`missing or malformed required field "${key}"`, `Use the exact codex-cli 0.147.0 ${key} field.`);
40
+ throw new CodexDecoderError(`missing or malformed required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
32
41
  }
33
42
  return candidate;
34
43
  }
35
44
  function requiredNullableString(value, key) {
36
45
  if (!Object.hasOwn(value, key))
37
- throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli 0.147.0 ${key} field.`);
46
+ throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
38
47
  const candidate = own(value, key);
39
48
  if (candidate !== null && typeof candidate !== 'string') {
40
49
  throw new CodexDecoderError(`malformed required field "${key}"`, `${key} must be a string or null.`);
@@ -43,7 +52,7 @@ function requiredNullableString(value, key) {
43
52
  }
44
53
  function requiredUnknown(value, key) {
45
54
  if (!Object.hasOwn(value, key))
46
- throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli 0.147.0 ${key} field.`);
55
+ throw new CodexDecoderError(`missing required field "${key}"`, `Use the exact codex-cli ${FOUR_EVENT_CONTRACT_VERSION} ${key} field.`);
47
56
  return own(value, key);
48
57
  }
49
58
  function validateCommon(raw, needsTurn) {
@@ -61,7 +70,7 @@ export function decodeCodexInput(raw) {
61
70
  const eventName = requiredString(raw, 'hook_event_name');
62
71
  const kind = EVENT_KINDS.get(eventName);
63
72
  if (!kind)
64
- throw new CodexDecoderError(`unknown hook_event_name "${eventName}"`, 'Configure only PreToolUse, PostToolUse, UserPromptSubmit, or SessionStart.');
73
+ throw new CodexDecoderError(`unknown hook_event_name "${eventName}"`, 'Configure only PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, or Stop.');
65
74
  const common = validateCommon(raw, kind !== 'session_start');
66
75
  let context;
67
76
  let rawPayload = raw;
@@ -76,6 +85,16 @@ export function decodeCodexInput(raw) {
76
85
  else if (kind === 'before_prompt_build') {
77
86
  context = { ...common, promptContent: requiredString(raw, 'prompt') };
78
87
  }
88
+ else if (kind === 'turn_complete') {
89
+ // Stop is the G1-verified turn-complete event (probe report §2). Its
90
+ // payload carries stop_hook_active (required boolean); PD consumes the
91
+ // already-flushed transcript, never last_assistant_message directly.
92
+ const stopHookActive = own(raw, 'stop_hook_active');
93
+ if (typeof stopHookActive !== 'boolean') {
94
+ throw new CodexDecoderError('missing or malformed required field "stop_hook_active"', `Use the exact codex-cli ${CODEX_INGESTION_MIN_VERSION} stop_hook_active field.`);
95
+ }
96
+ context = { ...common };
97
+ }
79
98
  else {
80
99
  context = { ...common, source: requiredString(raw, 'source') };
81
100
  }
@@ -4,7 +4,11 @@ const SUBSCRIBED_EVENTS = [
4
4
  'after_tool_call',
5
5
  'before_prompt_build',
6
6
  'session_start',
7
- // 'session_end' deferred to post-MVP
7
+ // Stop = turn_complete is the G1-verified turn-complete event (probe
8
+ // report §2); it drives bounded governance-observation ingestion, not a
9
+ // dispatch route. session_end stays deferred (SPEC §8: never register both
10
+ // Stop and SessionEnd for turn completion).
11
+ 'turn_complete',
8
12
  ];
9
13
  export class CodexHooksHostAdapter {
10
14
  hostId = 'codex';
package/dist/index.d.ts CHANGED
@@ -9,3 +9,11 @@ export { CodexHooksHostAdapter } from './host-adapter.js';
9
9
  export { processHookInvocation } from './pd-hook.js';
10
10
  export type { PdHookResult } from './pd-hook.js';
11
11
  export * from './codec/index.js';
12
+ export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
13
+ export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
14
+ export type { TranscriptPathValidation, TranscriptFileIdentity } from './ingestion/transcript-path.js';
15
+ export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
16
+ export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
17
+ export type { CodexIngestionOptions, CodexIngestionOutcome } from './ingestion/ingestion.js';
18
+ export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
19
+ export type { TranscriptPort, TranscriptExpectedIdentity, DecodedDelta, TranscriptDecodeStop } from './ingestion/transcript-decoder.js';
package/dist/index.js CHANGED
@@ -8,3 +8,8 @@
8
8
  export { CodexHooksHostAdapter } from './host-adapter.js';
9
9
  export { processHookInvocation } from './pd-hook.js';
10
10
  export * from './codec/index.js';
11
+ export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
12
+ export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
13
+ export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
14
+ export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
15
+ export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
@@ -0,0 +1,19 @@
1
+ export type CodexHomeResolution = {
2
+ ok: true;
3
+ home: string;
4
+ } | {
5
+ ok: false;
6
+ reason: string;
7
+ nextAction: string;
8
+ };
9
+ /**
10
+ * Canonicalize a Windows path to its final long form. Node's JS realpath
11
+ * preserves 8.3 short-name segments (ADMINI~1), while hook payloads carry
12
+ * the long form — containment comparisons must use one canonical form, so
13
+ * prefer the native realpath (GetFinalPathNameByHandle) and fall back to
14
+ * the JS implementation where native is unavailable.
15
+ */
16
+ export declare function canonicalizePath(target: string): string;
17
+ export declare function resolveCodexHome(env?: {
18
+ CODEX_HOME?: string | undefined;
19
+ }): CodexHomeResolution;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Codex home resolution (G1 probe report §7, source-pinned from
3
+ * codex-rs/utils/home-dir/src/lib.rs find_codex_home).
4
+ *
5
+ * Rules: CODEX_HOME, when set and non-empty, MUST already exist and be a
6
+ * directory and is canonicalized; a missing path or a file path is an error.
7
+ * When unset, the home comes from the OS home directory plus `.codex`, and
8
+ * existence is NOT verified. There is one OS-generic implementation.
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ /**
14
+ * Canonicalize a Windows path to its final long form. Node's JS realpath
15
+ * preserves 8.3 short-name segments (ADMINI~1), while hook payloads carry
16
+ * the long form — containment comparisons must use one canonical form, so
17
+ * prefer the native realpath (GetFinalPathNameByHandle) and fall back to
18
+ * the JS implementation where native is unavailable.
19
+ */
20
+ export function canonicalizePath(target) {
21
+ try {
22
+ return fs.realpathSync.native(target);
23
+ }
24
+ catch {
25
+ return fs.realpathSync(target);
26
+ }
27
+ }
28
+ export function resolveCodexHome(env = process.env) {
29
+ const configured = env.CODEX_HOME;
30
+ if (configured !== undefined && configured.trim() !== '') {
31
+ try {
32
+ const stats = fs.statSync(configured);
33
+ if (!stats.isDirectory()) {
34
+ return { ok: false, reason: 'codex_home_not_directory', nextAction: 'CODEX_HOME must point at an existing directory (Codex fatal-error contract).' };
35
+ }
36
+ return { ok: true, home: canonicalizePath(configured) };
37
+ }
38
+ catch {
39
+ return { ok: false, reason: 'codex_home_unavailable', nextAction: 'CODEX_HOME is set but does not exist; unset it or create the directory.' };
40
+ }
41
+ }
42
+ return { ok: true, home: path.join(os.homedir(), '.codex') };
43
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Supported Codex ingestion version contract (Codex Governance Closure
3
+ * Slice A; ADR-0020 §11.2 + G1 probe report §9).
4
+ *
5
+ * The G1 contract baseline is: minimum supported Codex 0.148.0, verified
6
+ * on-device at 0.150.1. The transcript itself carries `session_meta.
7
+ * payload.cli_version`, which is the version signal available inside a hook
8
+ * subprocess (hook payloads carry no version field — G1 fixture contract).
9
+ *
10
+ * Older-than-minimum and newer-than-verified versions both degrade
11
+ * explicitly; an unknown version must never silently guess record fields.
12
+ * Adopting a newer Codex version requires re-running the contract probe and
13
+ * refreshing the fixtures (G1 report §9 drift-detection boundary).
14
+ */
15
+ export declare const CODEX_INGESTION_MIN_VERSION = "0.148.0";
16
+ export declare const CODEX_INGESTION_VERIFIED_VERSION = "0.150.1";
17
+ export type CodexVersionClassification = {
18
+ status: 'supported';
19
+ } | {
20
+ status: 'unsupported_below';
21
+ reason: 'unsupported_codex_version';
22
+ } | {
23
+ status: 'unverified_above';
24
+ reason: 'codex_version_unverified';
25
+ } | {
26
+ status: 'unknown';
27
+ reason: 'codex_version_unverified';
28
+ };
29
+ export declare const CODEX_VERSION_NEXT_ACTION = "rerun the Codex contract probe / update supported fixtures (docs/architecture/CODEX_G1_CONTRACT_PROBE_REPORT.md \u00A79)";
30
+ export declare function classifyCodexVersion(cliVersion: string | null | undefined): CodexVersionClassification;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Supported Codex ingestion version contract (Codex Governance Closure
3
+ * Slice A; ADR-0020 §11.2 + G1 probe report §9).
4
+ *
5
+ * The G1 contract baseline is: minimum supported Codex 0.148.0, verified
6
+ * on-device at 0.150.1. The transcript itself carries `session_meta.
7
+ * payload.cli_version`, which is the version signal available inside a hook
8
+ * subprocess (hook payloads carry no version field — G1 fixture contract).
9
+ *
10
+ * Older-than-minimum and newer-than-verified versions both degrade
11
+ * explicitly; an unknown version must never silently guess record fields.
12
+ * Adopting a newer Codex version requires re-running the contract probe and
13
+ * refreshing the fixtures (G1 report §9 drift-detection boundary).
14
+ */
15
+ export const CODEX_INGESTION_MIN_VERSION = '0.148.0';
16
+ export const CODEX_INGESTION_VERIFIED_VERSION = '0.150.1';
17
+ export const CODEX_VERSION_NEXT_ACTION = 'rerun the Codex contract probe / update supported fixtures (docs/architecture/CODEX_G1_CONTRACT_PROBE_REPORT.md §9)';
18
+ function isDigits(value) {
19
+ return value.length > 0 && /^[0-9]+$/.test(value);
20
+ }
21
+ function parseSemver(value) {
22
+ const parts = value.trim().split('.');
23
+ if (parts.length < 3)
24
+ return null;
25
+ const [major, minor, patch] = parts;
26
+ if (major === undefined || minor === undefined || patch === undefined)
27
+ return null;
28
+ if (!isDigits(major) || !isDigits(minor) || !isDigits(patch))
29
+ return null;
30
+ return { major: Number(major), minor: Number(minor), patch: Number(patch) };
31
+ }
32
+ function compareSemver(a, b) {
33
+ if (a.major !== b.major)
34
+ return a.major - b.major;
35
+ if (a.minor !== b.minor)
36
+ return a.minor - b.minor;
37
+ return a.patch - b.patch;
38
+ }
39
+ export function classifyCodexVersion(cliVersion) {
40
+ if (typeof cliVersion !== 'string' || cliVersion.trim().length === 0) {
41
+ return { status: 'unknown', reason: 'codex_version_unverified' };
42
+ }
43
+ const version = parseSemver(cliVersion);
44
+ const min = parseSemver(CODEX_INGESTION_MIN_VERSION);
45
+ const verified = parseSemver(CODEX_INGESTION_VERIFIED_VERSION);
46
+ if (!version || !min || !verified)
47
+ return { status: 'unknown', reason: 'codex_version_unverified' };
48
+ if (compareSemver(version, min) < 0)
49
+ return { status: 'unsupported_below', reason: 'unsupported_codex_version' };
50
+ if (compareSemver(version, verified) > 0)
51
+ return { status: 'unverified_above', reason: 'codex_version_unverified' };
52
+ return { status: 'supported' };
53
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Codex conversation ingestion orchestrator (Codex Governance Closure
3
+ * Slice A; SPEC rev 2 §7/§9/§17).
4
+ *
5
+ * Called by pd-hook ONLY after both `host.codex` and
6
+ * `codex_conversation_ingestion` are enabled — the flag check happens
7
+ * before any transcript filesystem I/O, so flag-off means this module is
8
+ * never entered and the transcript boundary receives zero calls (SPEC §10
9
+ * hard privacy invariant, proven by a port spy test).
10
+ *
11
+ * Responsibilities: extract validated ingestion fields from the raw hook
12
+ * payload (Codex protocol facts live in the adapter, not host-runtime),
13
+ * authorize the transcript path, run bounded incremental decoding from the
14
+ * durable checkpoint, apply the supported-version guard, and hand the
15
+ * projected observations to the host-neutral host-runtime seam.
16
+ *
17
+ * Stop (`turn_complete`) is the turn-complete ingestion trigger (G1 §2);
18
+ * live `UserPromptSubmit`/`PostToolUse` contribute live observations that
19
+ * the transcript replay later converges with (SPEC §10 source precedence).
20
+ */
21
+ import type { HostEventKind } from '@principles/core/host';
22
+ import { type TranscriptPort } from './transcript-decoder.js';
23
+ export interface CodexIngestionOptions {
24
+ readonly workspaceDir: string;
25
+ readonly env?: {
26
+ CODEX_HOME?: string | undefined;
27
+ };
28
+ readonly now?: Date;
29
+ readonly port?: TranscriptPort;
30
+ }
31
+ export type CodexIngestionOutcome = {
32
+ status: 'ok';
33
+ inserted: number;
34
+ enriched: number;
35
+ duplicates: number;
36
+ warnings: readonly string[];
37
+ lagBytes: number;
38
+ } | {
39
+ status: 'degraded';
40
+ reason: string;
41
+ nextAction: string;
42
+ warnings: readonly string[];
43
+ };
44
+ /** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
45
+ export declare function setCodexTranscriptPortForTest(port: TranscriptPort | null): void;
46
+ export declare function ingestCodexConversation(rawPayload: unknown, kind: HostEventKind, options: CodexIngestionOptions): CodexIngestionOutcome;
@@ -0,0 +1,234 @@
1
+ import { ingestGovernanceObservations, readGovernanceCheckpoint, } from '@principles/host-runtime';
2
+ import { resolveCodexHome } from './codex-home.js';
3
+ import { validateCodexTranscriptPath } from './transcript-path.js';
4
+ import { classifyCodexVersion, CODEX_VERSION_NEXT_ACTION } from './codex-version.js';
5
+ import { decodeTranscriptWindow, createNodeTranscriptPort, CODEX_INGESTION_MAX_BATCH_BYTES, TranscriptReplacedError } from './transcript-decoder.js';
6
+ let activePort = null;
7
+ /** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
8
+ export function setCodexTranscriptPortForTest(port) {
9
+ activePort = port;
10
+ }
11
+ function activeTranscriptPort(options) {
12
+ return options.port ?? activePort ?? createNodeTranscriptPort();
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function own(value, key) {
18
+ return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
19
+ }
20
+ function extractFields(raw) {
21
+ if (!isRecord(raw))
22
+ return null;
23
+ const transcriptPath = own(raw, 'transcript_path');
24
+ if (transcriptPath !== null && typeof transcriptPath !== 'string')
25
+ return null;
26
+ const sessionId = own(raw, 'session_id');
27
+ const turnId = own(raw, 'turn_id');
28
+ const prompt = own(raw, 'prompt');
29
+ const toolUseId = own(raw, 'tool_use_id');
30
+ const toolName = own(raw, 'tool_name');
31
+ return {
32
+ transcriptPath: transcriptPath ?? null,
33
+ sessionId: typeof sessionId === 'string' ? sessionId : '',
34
+ turnId: typeof turnId === 'string' ? turnId : null,
35
+ prompt: typeof prompt === 'string' ? prompt : null,
36
+ toolUseId: typeof toolUseId === 'string' ? toolUseId : null,
37
+ toolName: typeof toolName === 'string' ? toolName : null,
38
+ toolInput: own(raw, 'tool_input'),
39
+ toolResponse: own(raw, 'tool_response'),
40
+ };
41
+ }
42
+ function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
43
+ const nowIso = now.toISOString();
44
+ let observation = null;
45
+ if (fields.turnId !== null && fields.prompt !== null) {
46
+ observation = {
47
+ hostKind: 'codex',
48
+ rolloutIdentity,
49
+ rootSessionId: fields.sessionId,
50
+ hostTurnId: fields.turnId,
51
+ kind: 'user_turn',
52
+ logicalObservationKey: `codex|${rolloutIdentity}|${fields.turnId}|user`,
53
+ visibleText: fields.prompt,
54
+ source: 'live_hook',
55
+ completeness: 'complete',
56
+ observedAt: nowIso,
57
+ };
58
+ }
59
+ else if (fields.turnId !== null && fields.toolUseId !== null) {
60
+ observation = {
61
+ hostKind: 'codex',
62
+ rolloutIdentity,
63
+ rootSessionId: fields.sessionId,
64
+ hostTurnId: fields.turnId,
65
+ kind: 'tool_call',
66
+ logicalObservationKey: `codex|${rolloutIdentity}|${fields.toolUseId}`,
67
+ toolUseId: fields.toolUseId,
68
+ toolFacts: {
69
+ toolName: fields.toolName,
70
+ params: fields.toolInput ?? null,
71
+ result: fields.toolResponse ?? null,
72
+ },
73
+ source: 'live_hook',
74
+ completeness: 'complete',
75
+ observedAt: nowIso,
76
+ };
77
+ }
78
+ if (observation === null) {
79
+ return { status: 'degraded', reason: 'transcript_path_invalid', nextAction: 'the live event lacks turn/prompt/tool identity; no observation was written.', warnings: [] };
80
+ }
81
+ const result = ingestGovernanceObservations({
82
+ workspaceDir,
83
+ observations: [observation],
84
+ now,
85
+ });
86
+ if (!result.ok)
87
+ return { status: 'degraded', reason: result.reason ?? 'governance_write_failed', nextAction: result.nextAction ?? 'inspect the workspace trajectory database.', warnings: result.warnings };
88
+ return { status: 'ok', inserted: result.inserted, enriched: result.enriched, duplicates: result.duplicates, warnings: result.warnings, lagBytes: 0 };
89
+ }
90
+ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentity, workspaceDir, now, port }) {
91
+ const checkpoint = readGovernanceCheckpoint({ workspaceDir, hostKind: 'codex', rolloutIdentity });
92
+ if (checkpoint !== null && !('byteOffset' in checkpoint) && 'ok' in checkpoint && checkpoint.ok === false) {
93
+ return { status: 'degraded', reason: checkpoint.reason, nextAction: checkpoint.nextAction, warnings: [] };
94
+ }
95
+ const existing = checkpoint !== null && 'byteOffset' in checkpoint ? checkpoint : null;
96
+ const offset = existing !== null ? existing.byteOffset : 0;
97
+ let window;
98
+ try {
99
+ // Post-open revalidation (SPEC §9): the port must prove the opened
100
+ // object still carries the identity the validator approved.
101
+ window = port.read({ canonicalPath, offset, maxBytes: CODEX_INGESTION_MAX_BATCH_BYTES, expectedIdentity: identity });
102
+ }
103
+ catch (error) {
104
+ if (error instanceof TranscriptReplacedError) {
105
+ return { status: 'degraded', reason: 'transcript_replaced', nextAction: 'the transcript changed identity after validation (replacement or symlink swap); refusing to read — the next Stop revalidates the current file.', warnings: [] };
106
+ }
107
+ const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
108
+ return { status: 'degraded', reason: `transcript_read_failed:${detail}`, nextAction: 'verify the transcript file still exists and is a regular file inside the Codex sessions root.', warnings: [] };
109
+ }
110
+ if (offset > window.fileSize) {
111
+ // The file shrank below the committed cursor: replaced or truncated —
112
+ // never guess; hold the checkpoint and degrade explicitly.
113
+ return { status: 'degraded', reason: 'checkpoint_inconsistent', nextAction: 'the transcript is shorter than the committed checkpoint; run the audited recovery/quarantine path once available or re-ingest from a fresh rollout.', warnings: [] };
114
+ }
115
+ const byteBoundReached = offset + window.bytes.length < window.fileSize;
116
+ const decoded = decodeTranscriptWindow({
117
+ bytes: window.bytes,
118
+ fileOffset: offset,
119
+ byteBoundReached,
120
+ rolloutIdentity,
121
+ fallbackRootSessionId: existing !== null ? existing.rootSessionId : fields.sessionId,
122
+ nowIso: now.toISOString(),
123
+ });
124
+ // Supported-version guard (SPEC §9): the version signal lives in the
125
+ // transcript session_meta (or the committed checkpoint after the first
126
+ // batch). Anything below the verified floor or above the verified ceiling
127
+ // degrades explicitly — never guess record fields of an unknown contract.
128
+ const cliVersion = decoded.rolloutMeta.cliVersion ?? existing?.cliVersion ?? null;
129
+ const version = classifyCodexVersion(cliVersion);
130
+ if (version.status !== 'supported') {
131
+ return {
132
+ status: 'degraded',
133
+ reason: `${version.reason}:${cliVersion ?? 'unknown'}`,
134
+ nextAction: CODEX_VERSION_NEXT_ACTION,
135
+ warnings: decoded.warnings,
136
+ };
137
+ }
138
+ const degradations = [];
139
+ if (decoded.stop.kind === 'malformed') {
140
+ degradations.push({ reason: 'transcript_record_malformed', ...(decoded.stop.ordinal !== null ? { ordinal: decoded.stop.ordinal } : {}), nextAction: 'the record is stable-invalid; run the audited quarantine command once available (Slice D). Later records remain as lag.' });
141
+ }
142
+ else if (decoded.stop.kind === 'oversized_record') {
143
+ degradations.push({ reason: 'transcript_record_too_large', nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.' });
144
+ }
145
+ const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ?? fields.sessionId;
146
+ const result = ingestGovernanceObservations({
147
+ workspaceDir,
148
+ rollout: {
149
+ hostKind: 'codex',
150
+ rolloutIdentity,
151
+ rootSessionId,
152
+ ...(decoded.rolloutMeta.parentRolloutIdentity !== null ? { parentRolloutIdentity: decoded.rolloutMeta.parentRolloutIdentity } : {}),
153
+ ...(decoded.rolloutMeta.agentIdentity !== null ? { agentIdentity: decoded.rolloutMeta.agentIdentity } : {}),
154
+ ...(decoded.rolloutMeta.agentDepth !== null ? { agentDepth: decoded.rolloutMeta.agentDepth } : {}),
155
+ },
156
+ observations: decoded.observations,
157
+ checkpoint: {
158
+ hostKind: 'codex',
159
+ rolloutIdentity,
160
+ byteOffset: decoded.nextByteOffset,
161
+ lastOrdinal: decoded.lastOrdinal >= 0 ? decoded.lastOrdinal : existing?.lastOrdinal ?? 0,
162
+ cliVersion: cliVersion ?? undefined,
163
+ rootSessionId,
164
+ incompleteTail: decoded.stop.kind === 'incomplete_tail',
165
+ },
166
+ ...(degradations.length > 0 ? { degradations } : {}),
167
+ ...(decoded.compactionTimestamp !== null ? { compactionTimestamp: decoded.compactionTimestamp } : {}),
168
+ ...(decoded.rollbackTurns.length > 0 ? { rollbackTurns: decoded.rollbackTurns } : {}),
169
+ now,
170
+ });
171
+ const warnings = [...decoded.warnings, ...result.warnings];
172
+ if (!result.ok) {
173
+ return { status: 'degraded', reason: result.reason ?? 'governance_write_failed', nextAction: result.nextAction ?? 'inspect the workspace trajectory database.', warnings };
174
+ }
175
+ if (decoded.stop.kind === 'incomplete_tail') {
176
+ warnings.push('transcript_incomplete_tail');
177
+ }
178
+ if (decoded.stop.kind === 'malformed') {
179
+ // The store already committed the records before the malformed line and
180
+ // held the checkpoint at it; surface the stable failure loudly (SPEC §14.2).
181
+ return {
182
+ status: 'degraded',
183
+ reason: 'transcript_record_malformed',
184
+ nextAction: 'the record is stable-invalid; run the audited quarantine command once available (Slice D). Later records remain as lag.',
185
+ warnings,
186
+ };
187
+ }
188
+ if (decoded.stop.kind === 'oversized_record') {
189
+ return {
190
+ status: 'degraded',
191
+ reason: 'transcript_record_too_large',
192
+ nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.',
193
+ warnings,
194
+ };
195
+ }
196
+ return {
197
+ status: 'ok',
198
+ inserted: result.inserted,
199
+ enriched: result.enriched,
200
+ duplicates: result.duplicates,
201
+ warnings,
202
+ lagBytes: Math.max(0, window.fileSize - decoded.nextByteOffset),
203
+ };
204
+ }
205
+ export function ingestCodexConversation(rawPayload, kind, options) {
206
+ const fields = extractFields(rawPayload);
207
+ if (fields === null || fields.sessionId.length === 0) {
208
+ return { status: 'degraded', reason: 'transcript_path_invalid', nextAction: 'the hook payload lacks the required Codex identity fields.', warnings: [] };
209
+ }
210
+ if (fields.transcriptPath === null) {
211
+ // SPEC §9: never scan for another file; keep a neutral degraded result.
212
+ return { status: 'degraded', reason: 'transcript_unavailable', nextAction: 'Codex supplied no transcript for this event; nothing is read or fabricated.', warnings: [] };
213
+ }
214
+ const home = resolveCodexHome(options.env);
215
+ if (!home.ok)
216
+ return { status: 'degraded', reason: home.reason, nextAction: home.nextAction, warnings: [] };
217
+ const validated = validateCodexTranscriptPath(fields.transcriptPath, home.home);
218
+ if (!validated.ok)
219
+ return { status: 'degraded', reason: validated.reason, nextAction: validated.nextAction, warnings: [] };
220
+ const now = options.now ?? new Date();
221
+ if (kind === 'before_prompt_build' || kind === 'after_tool_call') {
222
+ return ingestLiveObservation({ fields, rolloutIdentity: validated.rolloutIdentity, workspaceDir: options.workspaceDir, now });
223
+ }
224
+ return ingestTranscriptDelta({
225
+ fields,
226
+ canonicalPath: validated.canonicalPath,
227
+ identity: validated.identity,
228
+ rolloutIdentity: validated.rolloutIdentity,
229
+ workspaceDir: options.workspaceDir,
230
+ env: options.env ?? {},
231
+ now,
232
+ port: activeTranscriptPort(options),
233
+ });
234
+ }
@@ -0,0 +1,72 @@
1
+ import type { GovernanceObservationInput } from '@principles/host-runtime';
2
+ export declare const CODEX_INGESTION_MAX_BATCH_BYTES: number;
3
+ export declare const CODEX_INGESTION_MAX_BATCH_RECORDS = 256;
4
+ export interface TranscriptReadResult {
5
+ /** Raw bytes read from [offset, offset+maxBytes) — never a decoded string, so multi-byte UTF-8 never corrupts offset math. */
6
+ readonly bytes: Buffer;
7
+ readonly fileSize: number;
8
+ }
9
+ /** Identity the validator captured pre-open; the port must re-prove it post-open (SPEC §9 TOCTOU). */
10
+ export interface TranscriptExpectedIdentity {
11
+ readonly dev: number;
12
+ readonly ino: number;
13
+ readonly size: number;
14
+ readonly mtimeMs: number;
15
+ }
16
+ /** Arguments for one bounded transcript window read. */
17
+ export interface TranscriptReadRequest {
18
+ readonly canonicalPath: string;
19
+ readonly offset: number;
20
+ readonly maxBytes: number;
21
+ /** Pre-open identity from the validator; the port re-proves it post-open. */
22
+ readonly expectedIdentity?: TranscriptExpectedIdentity;
23
+ }
24
+ /** Filesystem boundary for the transcript read — injectable so tests can spy. */
25
+ export interface TranscriptPort {
26
+ read(request: TranscriptReadRequest): TranscriptReadResult;
27
+ }
28
+ /** Thrown by the port when the opened object is not the validated one (replacement/TOCTOU). */
29
+ export declare class TranscriptReplacedError extends Error {
30
+ constructor();
31
+ }
32
+ export declare function createNodeTranscriptPort(): TranscriptPort;
33
+ export type TranscriptDecodeStop = {
34
+ kind: 'eof';
35
+ } | {
36
+ kind: 'incomplete_tail';
37
+ } | {
38
+ kind: 'malformed';
39
+ ordinal: number | null;
40
+ } | {
41
+ kind: 'byte_bound';
42
+ } | {
43
+ kind: 'oversized_record';
44
+ };
45
+ export interface DecodedDelta {
46
+ readonly observations: readonly GovernanceObservationInput[];
47
+ readonly rolloutMeta: {
48
+ rootSessionId: string | null;
49
+ cliVersion: string | null;
50
+ parentRolloutIdentity: string | null;
51
+ agentIdentity: string | null;
52
+ agentDepth: number | null;
53
+ };
54
+ readonly compactionTimestamp: string | null;
55
+ readonly rollbackTurns: readonly number[];
56
+ readonly stop: TranscriptDecodeStop;
57
+ /** Checkpoint byte offset to commit: start of the first unconsumed byte. */
58
+ readonly nextByteOffset: number;
59
+ /** Highest ordinal consumed (for the checkpoint's last_ordinal). */
60
+ readonly lastOrdinal: number;
61
+ readonly warnings: readonly string[];
62
+ }
63
+ export interface DecodeTranscriptWindowInput {
64
+ readonly bytes: Buffer;
65
+ readonly fileOffset: number;
66
+ /** True when bytes.length did not reach EOF — the window was cut by the bounded read. */
67
+ readonly byteBoundReached: boolean;
68
+ readonly rolloutIdentity: string;
69
+ readonly fallbackRootSessionId: string | null;
70
+ readonly nowIso: string;
71
+ }
72
+ export declare function decodeTranscriptWindow(input: DecodeTranscriptWindowInput): DecodedDelta;
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Codex rollout transcript incremental decoder (Codex Governance Closure
3
+ * Slice A; SPEC rev 2 §9, G1 probe report §6).
4
+ *
5
+ * Decodes a bounded byte window from the durable checkpoint offset and
6
+ * projects ONLY governance-relevant visible facts (SPEC §12 privacy
7
+ * boundary): genuine visible user messages (`content_item_kinds[0] ===
8
+ * "user.text"` — host-injected context arrives as user-role records with
9
+ * other kinds), visible assistant commentary/final messages, and tool
10
+ * execution facts anchored on the `event_msg item_completed
11
+ * CommandExecution` record whose `item.id` IS the hook `tool_use_id` id
12
+ * space (the G1-verified bridge; the model-level `call_id` is a different
13
+ * space). Hidden reasoning, system/developer prompts, world_state,
14
+ * host-injected context, and unknown record bodies are identified by shape
15
+ * and dropped before persistence — never projected, logged, or emitted.
16
+ *
17
+ * Failure classes are distinct (SPEC §14): an incomplete final line (no
18
+ * trailing newline at EOF) is a transient append/flush boundary — the
19
+ * checkpoint does not advance past it and the next ingestion retries; a
20
+ * complete line that fails JSON parsing or envelope validation is a stable
21
+ * malformed record — decoding stops at it without advancing, with a
22
+ * structured reason. Unknown-but-well-formed record types are skipped with
23
+ * a bounded warning and DO advance.
24
+ */
25
+ import fs from 'node:fs';
26
+ export const CODEX_INGESTION_MAX_BATCH_BYTES = 1024 * 1024;
27
+ export const CODEX_INGESTION_MAX_BATCH_RECORDS = 256;
28
+ /** Thrown by the port when the opened object is not the validated one (replacement/TOCTOU). */
29
+ export class TranscriptReplacedError extends Error {
30
+ constructor() {
31
+ super('transcript_replaced_after_validation');
32
+ this.name = 'TranscriptReplacedError';
33
+ }
34
+ }
35
+ function identityMatches(opened, expected) {
36
+ // Size+mtime fingerprint must ALWAYS hold. When the platform reports a
37
+ // Number-safe inode on both sides, the inode must match too (it is what
38
+ // catches delete+recreate replacements whose inode got reused on Linux CI
39
+ // with a same-ms write; a same-size same-ms recreate remains outside the
40
+ // threat model for an append-only host-owned file). On platforms whose
41
+ // ino exceeds Number.MAX_SAFE_INTEGER (Windows file IDs), the inode is not
42
+ // reliably comparable and the fingerprint alone decides. A false refusal
43
+ // is the safe direction: the hook degrades observably and the next Stop
44
+ // revalidates and retries.
45
+ if (opened.size !== expected.size || opened.mtimeMs !== expected.mtimeMs)
46
+ return false;
47
+ const comparableIno = expected.ino !== 0 && expected.dev !== 0
48
+ && opened.ino !== 0 && Number.isSafeInteger(expected.ino) && Number.isSafeInteger(opened.ino);
49
+ if (comparableIno)
50
+ return opened.ino === expected.ino && opened.dev === expected.dev;
51
+ return true;
52
+ }
53
+ export function createNodeTranscriptPort() {
54
+ return {
55
+ read({ canonicalPath, offset, maxBytes, expectedIdentity }) {
56
+ const fd = fs.openSync(canonicalPath, 'r');
57
+ try {
58
+ // Post-open revalidation (G1 §7 / SPEC §9): fstat the OPEN OBJECT —
59
+ // not the path — and re-prove it is the regular file the validator
60
+ // approved, still at the expected identity. A path swapped between
61
+ // validation and open (replacement/symlink TOCTOU) is refused before
62
+ // a single byte is read.
63
+ const stats = fs.fstatSync(fd);
64
+ if (!stats.isFile())
65
+ throw new Error('transcript_is_not_regular_file');
66
+ if (expectedIdentity !== undefined && !identityMatches(stats, expectedIdentity)) {
67
+ throw new TranscriptReplacedError();
68
+ }
69
+ const length = Math.max(0, Math.min(maxBytes, stats.size - offset));
70
+ if (length === 0)
71
+ return { bytes: Buffer.alloc(0), fileSize: stats.size };
72
+ const buffer = Buffer.alloc(length);
73
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
74
+ // Post-read byte bound re-verification (EP-08): never trust the
75
+ // pre-read stat for the enforced cap.
76
+ const actual = bytesRead < 0 ? 0 : bytesRead;
77
+ if (actual > maxBytes)
78
+ throw new Error('transcript_read_exceeded_bound');
79
+ return { bytes: buffer.subarray(0, actual), fileSize: stats.size };
80
+ }
81
+ finally {
82
+ fs.closeSync(fd);
83
+ }
84
+ },
85
+ };
86
+ }
87
+ function isRecord(value) {
88
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
89
+ }
90
+ function own(value, key) {
91
+ return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
92
+ }
93
+ function metadataTurnId(payload) {
94
+ const passthrough = own(payload, 'internal_chat_message_metadata_passthrough');
95
+ if (!isRecord(passthrough))
96
+ return null;
97
+ const turnId = own(passthrough, 'turn_id');
98
+ return typeof turnId === 'string' && turnId.length > 0 ? turnId : null;
99
+ }
100
+ function contentItemKinds(payload) {
101
+ const passthrough = own(payload, 'internal_chat_message_metadata_passthrough');
102
+ if (!isRecord(passthrough))
103
+ return [];
104
+ const kinds = own(passthrough, 'content_item_kinds');
105
+ if (!Array.isArray(kinds))
106
+ return [];
107
+ return kinds.filter((kind) => typeof kind === 'string');
108
+ }
109
+ function joinContentText(payload, type) {
110
+ const content = own(payload, 'content');
111
+ if (!Array.isArray(content))
112
+ return null;
113
+ const parts = [];
114
+ for (const item of content) {
115
+ if (!isRecord(item))
116
+ continue;
117
+ if (own(item, 'type') === type && typeof own(item, 'text') === 'string')
118
+ parts.push(own(item, 'text'));
119
+ }
120
+ return parts.length > 0 ? parts.join('') : null;
121
+ }
122
+ function warnBounded(context, warning) {
123
+ if (context.warnings.length < 16)
124
+ context.warnings.push(warning);
125
+ }
126
+ function applySessionMeta(context, payload) {
127
+ const sessionId = own(payload, 'session_id');
128
+ if (typeof sessionId === 'string' && sessionId.length > 0 && context.meta.rootSessionId === null) {
129
+ // Root-session lineage only. For subagent rollouts this value is the
130
+ // PARENT thread id (G1 §4 collision trap) — rollout identity comes from
131
+ // the file uuid, never from session_meta.session_id.
132
+ context.meta.rootSessionId = sessionId;
133
+ }
134
+ const cliVersion = own(payload, 'cli_version');
135
+ if (typeof cliVersion === 'string' && cliVersion.length > 0 && context.meta.cliVersion === null) {
136
+ context.meta.cliVersion = cliVersion;
137
+ }
138
+ const source = own(payload, 'source');
139
+ const spawn = isRecord(source) ? own(source, 'subagent') : undefined;
140
+ const threadSpawn = isRecord(spawn) ? own(spawn, 'thread_spawn') : undefined;
141
+ if (isRecord(threadSpawn)) {
142
+ const parentThreadId = own(threadSpawn, 'parent_thread_id');
143
+ if (typeof parentThreadId === 'string' && parentThreadId.length > 0)
144
+ context.meta.parentRolloutIdentity = parentThreadId;
145
+ const nickname = own(threadSpawn, 'agent_nickname');
146
+ const agentPath = own(threadSpawn, 'agent_path');
147
+ const identity = typeof nickname === 'string' && nickname.length > 0 ? nickname : typeof agentPath === 'string' && agentPath.length > 0 ? agentPath : null;
148
+ if (identity !== null)
149
+ context.meta.agentIdentity = identity;
150
+ const depth = own(threadSpawn, 'depth');
151
+ if (typeof depth === 'number' && Number.isInteger(depth) && depth >= 0)
152
+ context.meta.agentDepth = depth;
153
+ }
154
+ else {
155
+ const forkedFrom = own(payload, 'forked_from_id');
156
+ if (typeof forkedFrom === 'string' && forkedFrom.length > 0 && context.meta.parentRolloutIdentity === null) {
157
+ context.meta.parentRolloutIdentity = forkedFrom;
158
+ }
159
+ }
160
+ }
161
+ function joinItemText(item) {
162
+ const content = own(item, 'content');
163
+ if (!Array.isArray(content))
164
+ return null;
165
+ const parts = [];
166
+ for (const entry of content) {
167
+ if (!isRecord(entry))
168
+ continue;
169
+ // UserMessage items use "text", AgentMessage items use "Text".
170
+ const type = own(entry, 'type');
171
+ if ((type === 'text' || type === 'Text') && typeof own(entry, 'text') === 'string') {
172
+ parts.push(own(entry, 'text'));
173
+ }
174
+ }
175
+ return parts.length > 0 ? parts.join('') : null;
176
+ }
177
+ function observationBase(context, turnId) {
178
+ return {
179
+ hostKind: 'codex',
180
+ rolloutIdentity: context.rolloutIdentity,
181
+ rootSessionId: context.meta.rootSessionId ?? context.fallbackRootSessionId ?? turnId,
182
+ hostTurnId: turnId,
183
+ };
184
+ }
185
+ function projectRecord(context, record) {
186
+ const { type, payload, ordinal, recordByteStart, timestamp } = record;
187
+ const observedAt = timestamp ?? context.nowIso;
188
+ if (type === 'session_meta') {
189
+ applySessionMeta(context, payload);
190
+ return;
191
+ }
192
+ if (type === 'compacted') {
193
+ // Marker: replacement_history becomes the logical history going forward;
194
+ // replaced records must not be re-imported as new turns (G1 §6). The
195
+ // store tombstones prior unpromoted observations at ingest time.
196
+ context.compactionTimestamp = observedAt;
197
+ return;
198
+ }
199
+ if (type === 'world_state' || type === 'inter_agent_communication_metadata') {
200
+ // Environment/host snapshots — identified and dropped (privacy boundary).
201
+ return;
202
+ }
203
+ if (type === 'turn_context') {
204
+ // Per-turn host configuration (sandbox/permissions/model) — known type,
205
+ // no governance projection, no warning.
206
+ return;
207
+ }
208
+ if (type === 'response_item') {
209
+ const payloadType = own(payload, 'type');
210
+ if (payloadType === 'reasoning')
211
+ return; // hidden/encrypted reasoning — never projected
212
+ if (payloadType === 'custom_tool_call' || payloadType === 'function_call') {
213
+ const turnId = metadataTurnId(payload);
214
+ const callId = own(payload, 'call_id');
215
+ const name = own(payload, 'name');
216
+ if (turnId !== null) {
217
+ const queue = context.modelCallsByTurn.get(turnId) ?? [];
218
+ queue.push({ callId: typeof callId === 'string' ? callId : null, name: typeof name === 'string' ? name : null });
219
+ context.modelCallsByTurn.set(turnId, queue);
220
+ }
221
+ return;
222
+ }
223
+ if (payloadType === 'message' && own(payload, 'role') === 'user') {
224
+ // 0.150.1 marks genuine visible user input with content_item_kinds[0]
225
+ // === "user.text"; host-injected context carries other kinds. 0.148.0
226
+ // does not emit content_item_kinds at all, so this channel is used ONLY
227
+ // when the discriminator exists — the authoritative cross-version user
228
+ // channel is the item_completed UserMessage record below.
229
+ const kinds = contentItemKinds(payload);
230
+ if (kinds.length === 0 || kinds[0] !== 'user.text')
231
+ return;
232
+ const turnId = metadataTurnId(payload);
233
+ if (turnId === null) {
234
+ warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
235
+ return;
236
+ }
237
+ const text = joinContentText(payload, 'input_text');
238
+ context.observations.push({
239
+ ...observationBase(context, turnId),
240
+ kind: 'user_turn',
241
+ logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|user`,
242
+ transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
243
+ recordOrdinal: ordinal,
244
+ recordByteStart,
245
+ visibleText: text ?? undefined,
246
+ source: 'transcript',
247
+ completeness: text !== null ? 'complete' : 'partial',
248
+ observedAt,
249
+ });
250
+ return;
251
+ }
252
+ // Assistant/developer response_item messages are not projected here: the
253
+ // item_completed AgentMessage channel is the cross-version visible
254
+ // assistant source (0.148.0 lacks content_item_kinds discriminators).
255
+ return;
256
+ }
257
+ if (type === 'event_msg') {
258
+ const payloadType = own(payload, 'type');
259
+ if (payloadType === 'item_completed') {
260
+ const item = own(payload, 'item');
261
+ if (!isRecord(item))
262
+ return;
263
+ const itemType = own(item, 'type');
264
+ const turnId = own(payload, 'turn_id');
265
+ if (itemType === 'UserMessage') {
266
+ // Only genuine visible user turns produce UserMessage completions —
267
+ // host-injected context never does (both supported versions).
268
+ if (typeof turnId !== 'string' || turnId.length === 0) {
269
+ warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
270
+ return;
271
+ }
272
+ const text = joinItemText(item);
273
+ context.observations.push({
274
+ ...observationBase(context, turnId),
275
+ kind: 'user_turn',
276
+ logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|user`,
277
+ transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
278
+ recordOrdinal: ordinal,
279
+ recordByteStart,
280
+ visibleText: text ?? undefined,
281
+ source: 'transcript',
282
+ completeness: text !== null ? 'complete' : 'partial',
283
+ observedAt,
284
+ });
285
+ return;
286
+ }
287
+ if (itemType === 'AgentMessage') {
288
+ if (typeof turnId !== 'string' || turnId.length === 0) {
289
+ warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
290
+ return;
291
+ }
292
+ const itemId = own(item, 'id');
293
+ if (typeof itemId !== 'string' || itemId.length === 0) {
294
+ warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
295
+ return;
296
+ }
297
+ const text = joinItemText(item);
298
+ // AgentMessage phase sits on the item (fixture-verified; keep the
299
+ // payload sibling as a tolerated alternative).
300
+ const phase = own(item, 'phase') ?? own(payload, 'phase');
301
+ context.observations.push({
302
+ ...observationBase(context, turnId),
303
+ kind: 'assistant_turn',
304
+ logicalObservationKey: `codex|${context.rolloutIdentity}|${turnId}|${itemId}`,
305
+ transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
306
+ recordOrdinal: ordinal,
307
+ recordByteStart,
308
+ assistantItemId: itemId,
309
+ phase: typeof phase === 'string' ? phase : undefined,
310
+ visibleText: text ?? undefined,
311
+ source: 'transcript',
312
+ completeness: text !== null ? 'complete' : 'partial',
313
+ observedAt,
314
+ });
315
+ return;
316
+ }
317
+ if (itemType === 'CommandExecution') {
318
+ const toolUseId = own(item, 'id');
319
+ if (typeof toolUseId !== 'string' || toolUseId.length === 0 || typeof turnId !== 'string' || turnId.length === 0) {
320
+ warnBounded(context, `observation_skipped_missing_identity:${ordinal}`);
321
+ return;
322
+ }
323
+ // FIFO-pair with the model-level call in the same turn for the tool
324
+ // name / call_id enrichment (execution order; G1 bridge fixture).
325
+ const queue = context.modelCallsByTurn.get(turnId);
326
+ const paired = queue !== undefined && queue.length > 0 ? queue.shift() : undefined;
327
+ if (queue !== undefined && queue.length === 0)
328
+ context.modelCallsByTurn.delete(turnId);
329
+ const exitCode = own(item, 'exit_code');
330
+ const command = own(item, 'command');
331
+ const stdout = own(item, 'stdout');
332
+ const stderr = own(item, 'stderr');
333
+ context.observations.push({
334
+ ...observationBase(context, turnId),
335
+ kind: 'tool_call',
336
+ logicalObservationKey: `codex|${context.rolloutIdentity}|${toolUseId}`,
337
+ transcriptRecordKey: `codex|${context.rolloutIdentity}|${ordinal}`,
338
+ recordOrdinal: ordinal,
339
+ recordByteStart,
340
+ toolUseId,
341
+ transcriptToolCallId: paired?.callId ?? undefined,
342
+ toolFacts: {
343
+ toolName: paired?.name ?? null,
344
+ exitCode: typeof exitCode === 'number' ? exitCode : null,
345
+ command: Array.isArray(command) ? command.filter((part) => typeof part === 'string').slice(0, 8) : null,
346
+ stdout: typeof stdout === 'string' ? stdout : null,
347
+ stderr: typeof stderr === 'string' ? stderr : null,
348
+ },
349
+ source: 'transcript',
350
+ completeness: 'complete',
351
+ observedAt,
352
+ });
353
+ return;
354
+ }
355
+ return;
356
+ }
357
+ if (payloadType === 'thread_rolled_back') {
358
+ // Documented rule (G1 runtime contract, thread_rollout_truncation.rs):
359
+ // rollback appends a marker carrying num_turns; physical records remain
360
+ // while effective logical history is truncated.
361
+ const numTurns = own(payload, 'num_turns');
362
+ if (typeof numTurns === 'number' && Number.isInteger(numTurns) && numTurns > 0) {
363
+ context.rollbackTurns.push(numTurns);
364
+ }
365
+ return;
366
+ }
367
+ // task_started / turn_context / token_count / task_complete /
368
+ // thread_settings_applied: known turn bookkeeping — no projection.
369
+ return;
370
+ }
371
+ warnBounded(context, `unknown_record_type_skipped:${type}`);
372
+ }
373
+ function decodeLine(lineBytes) {
374
+ const text = lineBytes.toString('utf8');
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(text);
378
+ }
379
+ catch {
380
+ return { record: null, type: null, ordinal: null, timestamp: null, payload: null };
381
+ }
382
+ if (!isRecord(parsed))
383
+ return { record: null, type: null, ordinal: null, timestamp: null, payload: null };
384
+ const type = own(parsed, 'type');
385
+ const ordinal = own(parsed, 'ordinal');
386
+ const timestamp = own(parsed, 'timestamp');
387
+ const payload = own(parsed, 'payload');
388
+ const validEnvelope = typeof type === 'string' && type.length > 0
389
+ && typeof ordinal === 'number' && Number.isInteger(ordinal)
390
+ && (payload === undefined || isRecord(payload));
391
+ if (!validEnvelope) {
392
+ return { record: parsed, type: null, ordinal: typeof ordinal === 'number' && Number.isInteger(ordinal) ? ordinal : null, timestamp: null, payload: null };
393
+ }
394
+ return {
395
+ record: parsed,
396
+ type,
397
+ ordinal,
398
+ timestamp: typeof timestamp === 'string' ? timestamp : null,
399
+ payload: payload === undefined ? {} : payload,
400
+ };
401
+ }
402
+ function finish(context, nextByteOffset, lastOrdinal) {
403
+ return {
404
+ observations: context.observations,
405
+ rolloutMeta: context.meta,
406
+ compactionTimestamp: context.compactionTimestamp,
407
+ rollbackTurns: context.rollbackTurns,
408
+ nextByteOffset,
409
+ lastOrdinal,
410
+ warnings: context.warnings,
411
+ };
412
+ }
413
+ export function decodeTranscriptWindow(input) {
414
+ const context = {
415
+ rolloutIdentity: input.rolloutIdentity,
416
+ fallbackRootSessionId: input.fallbackRootSessionId,
417
+ nowIso: input.nowIso,
418
+ observations: [],
419
+ meta: { rootSessionId: null, cliVersion: null, parentRolloutIdentity: null, agentIdentity: null, agentDepth: null },
420
+ modelCallsByTurn: new Map(),
421
+ compactionTimestamp: null,
422
+ rollbackTurns: [],
423
+ warnings: [],
424
+ };
425
+ const { bytes } = input;
426
+ if (bytes.length === 0) {
427
+ return { ...finish(context, input.fileOffset, -1), stop: { kind: 'eof' } };
428
+ }
429
+ // UTF-8 multi-byte sequences never contain 0x0A, so splitting the byte
430
+ // window at newline bytes is safe and keeps the checkpoint byte-exact.
431
+ const lastNewline = bytes.lastIndexOf(0x0a);
432
+ const completeEnd = lastNewline + 1; // exclusive byte end of the newline-terminated region
433
+ const tailBytes = bytes.subarray(completeEnd); // bytes after the final newline (no terminator)
434
+ let cursor = input.fileOffset;
435
+ let lastOrdinal = -1;
436
+ let recordsConsumed = 0;
437
+ let lineStart = 0;
438
+ while (lineStart < completeEnd) {
439
+ const lineEnd = bytes.indexOf(0x0a, lineStart);
440
+ if (lineEnd === -1 || lineEnd >= completeEnd)
441
+ break;
442
+ const lineBytes = bytes.subarray(lineStart, lineEnd);
443
+ const recordByteStart = cursor;
444
+ cursor = input.fileOffset + lineEnd + 1;
445
+ lineStart = lineEnd + 1;
446
+ if (lineBytes.length === 0 || lineBytes.toString('utf8').trim().length === 0)
447
+ continue;
448
+ if (recordsConsumed >= CODEX_INGESTION_MAX_BATCH_RECORDS) {
449
+ return { ...finish(context, recordByteStart, lastOrdinal), stop: { kind: 'byte_bound' } };
450
+ }
451
+ const line = decodeLine(lineBytes);
452
+ if (line.record === null || line.type === null) {
453
+ // A newline-terminated line that does not parse (or fails the record
454
+ // envelope) is a stable malformed record: stop here without advancing
455
+ // past it (SPEC §14.2).
456
+ return { ...finish(context, recordByteStart, lastOrdinal), stop: { kind: 'malformed', ordinal: line.ordinal } };
457
+ }
458
+ recordsConsumed += 1;
459
+ lastOrdinal = line.ordinal;
460
+ projectRecord(context, { type: line.type, payload: line.payload ?? {}, ordinal: line.ordinal, recordByteStart, timestamp: line.timestamp });
461
+ }
462
+ if (tailBytes.length > 0) {
463
+ const tailStart = cursor; // byte offset where the unterminated tail begins
464
+ if (input.byteBoundReached) {
465
+ // The window ended mid-line because of the byte bound — the line is not
466
+ // known-incomplete; leave it wholly unread and report bounded lag.
467
+ if (lastNewline === -1) {
468
+ return { ...finish(context, input.fileOffset, lastOrdinal), stop: { kind: 'oversized_record' } };
469
+ }
470
+ return { ...finish(context, tailStart, lastOrdinal), stop: { kind: 'byte_bound' } };
471
+ }
472
+ const line = decodeLine(tailBytes);
473
+ if (line.record !== null && line.type !== null) {
474
+ // A parseable final line without a trailing newline is a complete
475
+ // record; consume it and advance the checkpoint to EOF.
476
+ lastOrdinal = line.ordinal;
477
+ projectRecord(context, { type: line.type, payload: line.payload ?? {}, ordinal: line.ordinal, recordByteStart: tailStart, timestamp: line.timestamp });
478
+ return { ...finish(context, tailStart + tailBytes.length, lastOrdinal), stop: { kind: 'eof' } };
479
+ }
480
+ // Incomplete final line: transient append/flush boundary — do not
481
+ // advance past the previous record; the next ingestion retries (SPEC §14.1).
482
+ return { ...finish(context, tailStart, lastOrdinal), stop: { kind: 'incomplete_tail' } };
483
+ }
484
+ return { ...finish(context, cursor, lastOrdinal), stop: input.byteBoundReached ? { kind: 'byte_bound' } : { kind: 'eof' } };
485
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * File identity captured at validation time. The open/read seam re-proves
3
+ * the opened object still has this identity BEFORE any byte is read — the
4
+ * G1 §7 / SPEC §9 post-open revalidation that closes the validate→open
5
+ * TOCTOU window (path replacement, symlink swap).
6
+ */
7
+ export interface TranscriptFileIdentity {
8
+ readonly dev: number;
9
+ readonly ino: number;
10
+ readonly size: number;
11
+ readonly mtimeMs: number;
12
+ }
13
+ export type TranscriptPathValidation = {
14
+ ok: true;
15
+ canonicalPath: string;
16
+ rolloutIdentity: string;
17
+ identity: TranscriptFileIdentity;
18
+ } | {
19
+ ok: false;
20
+ reason: 'transcript_path_invalid' | 'transcript_path_outside_codex_home';
21
+ nextAction: string;
22
+ };
23
+ export declare function validateCodexTranscriptPath(transcriptPath: string, codexHome: string): TranscriptPathValidation;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Codex transcript path authorization (Codex Governance Closure SPEC rev 2
3
+ * §9; Slice A SPEC §11).
4
+ *
5
+ * The ONLY valid transcript source is the path explicitly supplied by the
6
+ * authenticated Codex hook — never a scan of `$CODEX_HOME/sessions`, never a
7
+ * "latest session" guess. Validation covers the real repository/OS path
8
+ * model: absolute path, `..` traversal rejection, canonical (realpath)
9
+ * normalization, containment under the resolved Codex home's `sessions`
10
+ * root at segment boundaries, symlink/junction escape rejection (realpath
11
+ * collapses them; containment is checked on the canonical form), and a
12
+ * regular-file requirement. The rollout identity — one physical transcript,
13
+ * distinct from the root session id (G1 §4 subagent/fork traps) — is the
14
+ * uuid embedded in the Codex rollout file name.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { canonicalizePath } from './codex-home.js';
19
+ const UUID_HEX = /^[0-9a-fA-F]+$/;
20
+ function isHex(value) {
21
+ return UUID_HEX.test(value);
22
+ }
23
+ /** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
24
+ function parseRolloutFileName(fileName) {
25
+ if (!fileName.startsWith('rollout-') || !fileName.endsWith('.jsonl'))
26
+ return null;
27
+ const stem = fileName.slice('rollout-'.length, -'.jsonl'.length);
28
+ const parts = stem.split('-');
29
+ if (parts.length < 6)
30
+ return null; // at least one timestamp segment + the five uuid groups
31
+ const [a, b, c, d, e] = parts.slice(-5);
32
+ if (a === undefined || b === undefined || c === undefined || d === undefined || e === undefined)
33
+ return null;
34
+ if (a.length !== 8 || b.length !== 4 || c.length !== 4 || d.length !== 4 || e.length !== 12)
35
+ return null;
36
+ if (!isHex(a) || !isHex(b) || !isHex(c) || !isHex(d) || !isHex(e))
37
+ return null;
38
+ return parts.slice(-5).join('-').toLowerCase();
39
+ }
40
+ function isAbsolutePath(value) {
41
+ return path.isAbsolute(value);
42
+ }
43
+ function hasParentTraversal(value) {
44
+ return value.split(/[\\/]+/).includes('..');
45
+ }
46
+ function isContained(root, candidate) {
47
+ const relative = path.relative(root, candidate);
48
+ return relative === '' || (relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative));
49
+ }
50
+ export function validateCodexTranscriptPath(transcriptPath, codexHome) {
51
+ if (!isAbsolutePath(transcriptPath) || hasParentTraversal(transcriptPath)) {
52
+ return { ok: false, reason: 'transcript_path_invalid', nextAction: 'Codex must supply an absolute transcript_path without traversal segments.' };
53
+ }
54
+ const fileName = path.basename(transcriptPath);
55
+ const rolloutIdentity = parseRolloutFileName(fileName);
56
+ if (rolloutIdentity === null) {
57
+ return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript file name does not match the Codex rollout contract (rollout-<timestamp>-<uuid>.jsonl).' };
58
+ }
59
+ let canonicalHome;
60
+ let canonicalPath;
61
+ try {
62
+ // Canonicalize both sides to the final long form: Node's JS realpath
63
+ // preserves 8.3 short-name segments while payloads carry long forms.
64
+ canonicalHome = canonicalizePath(codexHome);
65
+ canonicalPath = canonicalizePath(transcriptPath);
66
+ }
67
+ catch {
68
+ return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript path (or the Codex home) does not resolve to an existing location.' };
69
+ }
70
+ // realpath collapses symlinks/junctions; checking containment on the
71
+ // canonical form rejects escapes that traverse links out of the sessions root.
72
+ if (!isContained(path.join(canonicalHome, 'sessions'), canonicalPath)) {
73
+ return { ok: false, reason: 'transcript_path_outside_codex_home', nextAction: 'the transcript is not inside the resolved CODEX_HOME sessions root; refusing to read outside the authenticated Codex home.' };
74
+ }
75
+ let stats;
76
+ try {
77
+ stats = fs.statSync(canonicalPath);
78
+ }
79
+ catch {
80
+ return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript file disappeared during validation.' };
81
+ }
82
+ if (!stats.isFile()) {
83
+ return { ok: false, reason: 'transcript_path_invalid', nextAction: 'the transcript path is not a regular file.' };
84
+ }
85
+ const identity = { dev: stats.dev, ino: Number(stats.ino), size: stats.size, mtimeMs: stats.mtimeMs };
86
+ return { ok: true, canonicalPath, rolloutIdentity: rolloutIdentity.toLowerCase(), identity };
87
+ }
package/dist/pd-hook.js CHANGED
@@ -6,6 +6,7 @@ import { createProductionHostRuntime, loadPdConfigForPlugin, resolveNearestPdWor
6
6
  import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
7
7
  import { CodexHooksHostAdapter } from './host-adapter.js';
8
8
  import { CodexDecoderError, CodexEncoderError } from './codec/index.js';
9
+ import { ingestCodexConversation } from './ingestion/ingestion.js';
9
10
  const MAX_DIAGNOSTIC = 500;
10
11
  function diagnostic(reason, nextAction) {
11
12
  const boundedReason = reason.replace(/\s+/g, ' ').trim().slice(0, MAX_DIAGNOSTIC);
@@ -15,6 +16,32 @@ function diagnostic(reason, nextAction) {
15
16
  function errorMessage(error) {
16
17
  return error instanceof Error ? error.message.slice(0, MAX_DIAGNOSTIC) : 'unknown_error';
17
18
  }
19
+ // Bounded governance-observation ingestion (Codex Governance Closure Slice
20
+ // A). Runs only when BOTH host.codex and codex_conversation_ingestion are
21
+ // enabled — the flag gate below happens BEFORE any transcript path
22
+ // validation or filesystem I/O, so flag-off means the transcript boundary
23
+ // receives zero calls (SPEC §10 hard privacy invariant).
24
+ function runConversationIngestion(args) {
25
+ const { rawPayload, kind, workspaceDir, env } = args;
26
+ if (kind !== 'turn_complete' && kind !== 'before_prompt_build' && kind !== 'after_tool_call')
27
+ return [];
28
+ const diagnostics = [];
29
+ try {
30
+ const outcome = ingestCodexConversation(rawPayload, kind, { workspaceDir, env });
31
+ if (outcome.status === 'degraded') {
32
+ diagnostics.push(diagnostic(outcome.reason, outcome.nextAction));
33
+ }
34
+ else {
35
+ for (const warning of outcome.warnings.slice(0, 2)) {
36
+ diagnostics.push(diagnostic(warning, 'Inspect PD Workspace governance-observation state; ingestion continued.'));
37
+ }
38
+ }
39
+ }
40
+ catch (error) {
41
+ diagnostics.push(diagnostic(`codex_ingestion_unexpected:${errorMessage(error)}`, 'Retry the next Codex turn; if it repeats, inspect PD stderr and the Workspace trajectory database.'));
42
+ }
43
+ return diagnostics;
44
+ }
18
45
  export async function processHookInvocation(rawStdin, _env = process.env, cwd = process.cwd()) {
19
46
  let parsed;
20
47
  try {
@@ -50,6 +77,20 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
50
77
  if (flags['host.codex']?.enabled !== true) {
51
78
  return { stdout: {}, exitCode: 0, stderr: [diagnostic('host.codex_disabled', 'Set features.host.codex.enabled=true in the selected Workspace to enable PD.')] };
52
79
  }
80
+ const ingestionEnabled = flags.codex_conversation_ingestion?.enabled === true;
81
+ if (event.kind === 'turn_complete') {
82
+ // Stop is the turn-complete ingestion trigger (G1 §2): no dispatch route,
83
+ // and Codex's Stop output schema has no hookSpecificOutput — the neutral
84
+ // result is exactly `{}` on stdout (runtime contract: "PD emits empty
85
+ // stdout on Stop"). Flag-off preserves the zero-transcript-read invariant
86
+ // and emits ONE bounded structured feature_disabled fact per completed
87
+ // turn on stderr — never stdout, and never on the per-tool events (that
88
+ // would be per-event noise).
89
+ const stderr = ingestionEnabled
90
+ ? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
91
+ : [diagnostic('feature_disabled', 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.')];
92
+ return { stdout: {}, exitCode: 0, stderr };
93
+ }
53
94
  try {
54
95
  if (event.kind === 'session_start') {
55
96
  const health = await createProductionHostRuntime().health(resolution.workspaceDir);
@@ -57,8 +98,11 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
57
98
  return { stdout: {}, exitCode: 0, stderr: [diagnostic(health.reason ?? 'runtime_unhealthy', health.nextAction ?? 'Inspect the Workspace runtime.')] };
58
99
  return { stdout: adapter.encodeOutput({ decision: 'allow', source: event.source }, 'session_start'), exitCode: 0, stderr: [] };
59
100
  }
101
+ const ingestionDiagnostics = ingestionEnabled
102
+ ? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
103
+ : [];
60
104
  const result = await createProductionHostRuntime().dispatch(event);
61
- const stderr = (result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.'));
105
+ const stderr = [...(result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.')), ...ingestionDiagnostics];
62
106
  return { stdout: adapter.encodeOutput(result, event.kind), exitCode: 0, stderr };
63
107
  }
64
108
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/codex-adapter",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Codex CLI host adapter for Principles Disciple — implements HostAdapter interface for OpenAI Codex CLI's stdin/stdout JSON hook model (ADR-0020).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",