@principles/codex-adapter 0.1.1 → 0.2.0

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.
package/dist/index.d.ts CHANGED
@@ -13,7 +13,13 @@ export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
13
13
  export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
14
14
  export type { TranscriptPathValidation, TranscriptFileIdentity } from './ingestion/transcript-path.js';
15
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';
16
+ export { ingestCodexConversation, setCodexTranscriptPortForTest, ingestCodexTranscriptFromPath } from './ingestion/ingestion.js';
17
+ export type { CodexIngestionOptions, CodexIngestionOutcome, CodexTranscriptFromPathArgs } from './ingestion/ingestion.js';
18
18
  export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
19
19
  export type { TranscriptPort, TranscriptExpectedIdentity, DecodedDelta, TranscriptDecodeStop } from './ingestion/transcript-decoder.js';
20
+ export { catchUpCodexIngestion, CODEX_CATCH_UP_NEXT_ACTION } from './ingestion/catch-up.js';
21
+ export type { CodexCatchUpOptions, CodexCatchUpResult, CodexCatchUpRolloutResult } from './ingestion/catch-up.js';
22
+ export { locateCodexTranscriptByRolloutIdentity } from './ingestion/transcript-locate.js';
23
+ export type { CodexTranscriptLookup } from './ingestion/transcript-locate.js';
24
+ export { runCodexWorkspaceWorkerCycle, computeCodexWorkerStatusMode } from './worker/workspace-worker.js';
25
+ export type { CodexWorkerMode, CodexWorkerCycleResult, CodexWorkerCycleStepReport, CodexWorkerCycleOptions, CodexWorkerStatusEvaluation } from './worker/workspace-worker.js';
package/dist/index.js CHANGED
@@ -11,5 +11,9 @@ export * from './codec/index.js';
11
11
  export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
12
12
  export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
13
13
  export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
14
- export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
14
+ export { ingestCodexConversation, setCodexTranscriptPortForTest, ingestCodexTranscriptFromPath } from './ingestion/ingestion.js';
15
15
  export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
16
+ // PRI-624 Slice C: bounded catch-up + per-workspace worker cycle.
17
+ export { catchUpCodexIngestion, CODEX_CATCH_UP_NEXT_ACTION } from './ingestion/catch-up.js';
18
+ export { locateCodexTranscriptByRolloutIdentity } from './ingestion/transcript-locate.js';
19
+ export { runCodexWorkspaceWorkerCycle, computeCodexWorkerStatusMode } from './worker/workspace-worker.js';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Codex governance admission orchestration (Codex Governance Closure Slice B,
3
+ * PRI-623; SPEC §12/§13).
4
+ *
5
+ * Owns ONLY the Codex facts: mapping live hook payloads and decoded transcript
6
+ * observations onto the host-neutral admission candidates of
7
+ * `@principles/host-runtime` (`admitGovernanceSignals`), then driving the
8
+ * admitted pains through the existing continuation seams —
9
+ * `ensureGovernanceDiagnosticianTask` (Runtime V2 async enqueue, never an
10
+ * LLM) and `promoteAdmittedGovernanceEvidence` (Slice A's ≤12 + trigger +
11
+ * next-assistant window with its durable pending tail).
12
+ *
13
+ * No Codex JSONL knowledge crosses into host-runtime; no second pain or task
14
+ * authority exists here.
15
+ */
16
+ import { type GovernanceSignalCandidate } from '@principles/host-runtime';
17
+ import type { GovernanceObservationInput } from '@principles/host-runtime';
18
+ import type { PayloadFields } from './ingestion-fields.js';
19
+ export interface GovernanceAdmissionDegradation {
20
+ readonly reason: string;
21
+ readonly nextAction: string;
22
+ }
23
+ export interface GovernanceAdmissionRun {
24
+ /** Bounded structured degradations (only real failures; duplicate / rate-limited / ordinary conversation are normal outcomes and stay silent, SPEC §20/§21). */
25
+ readonly degradations: readonly GovernanceAdmissionDegradation[];
26
+ }
27
+ /** Live UserPromptSubmit payload → correction candidate (logical key mirrors the observation). */
28
+ export declare function buildLiveCorrectionCandidate(args: {
29
+ fields: PayloadFields;
30
+ rolloutIdentity: string;
31
+ }): GovernanceSignalCandidate | null;
32
+ /** Live PostToolUse payload → tool-failure candidate (logical key mirrors the observation). */
33
+ export declare function buildLiveToolCandidate(args: {
34
+ fields: PayloadFields;
35
+ rolloutIdentity: string;
36
+ }): GovernanceSignalCandidate | null;
37
+ /**
38
+ * Decoded transcript observations → candidates. User turns carry visibleText;
39
+ * tool calls carry the CommandExecution facts projection. The logical keys are
40
+ * the decoded ones, so a replay of an already-admitted live event is an
41
+ * observation-level no-op (SPEC §10).
42
+ */
43
+ export declare function buildTranscriptCandidates(observations: readonly GovernanceObservationInput[]): readonly GovernanceSignalCandidate[];
44
+ /**
45
+ * Run admission for the given candidates and drive every admitted pain
46
+ * through task-ensure + evidence promotion. Exactly-once is owned by the
47
+ * host-runtime seams; this orchestrator only sequences them and surfaces
48
+ * bounded degradations (duplicate / rate-limited / ordinary conversation are
49
+ * normal outcomes and produce no noise, SPEC §20/§21).
50
+ */
51
+ export declare function runGovernanceAdmission(args: {
52
+ workspaceDir: string;
53
+ candidates: readonly GovernanceSignalCandidate[];
54
+ }): Promise<GovernanceAdmissionRun>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Codex governance admission orchestration (Codex Governance Closure Slice B,
3
+ * PRI-623; SPEC §12/§13).
4
+ *
5
+ * Owns ONLY the Codex facts: mapping live hook payloads and decoded transcript
6
+ * observations onto the host-neutral admission candidates of
7
+ * `@principles/host-runtime` (`admitGovernanceSignals`), then driving the
8
+ * admitted pains through the existing continuation seams —
9
+ * `ensureGovernanceDiagnosticianTask` (Runtime V2 async enqueue, never an
10
+ * LLM) and `promoteAdmittedGovernanceEvidence` (Slice A's ≤12 + trigger +
11
+ * next-assistant window with its durable pending tail).
12
+ *
13
+ * No Codex JSONL knowledge crosses into host-runtime; no second pain or task
14
+ * authority exists here.
15
+ */
16
+ import { admitGovernanceSignals, ensureGovernanceContinuation, } from '@principles/host-runtime';
17
+ import { isRecord, own } from './ingestion-fields.js';
18
+ /** The live host-event source used by the identity derivation (parity with the dispatch handler). */
19
+ const CODEX_POST_TOOL_USE_SOURCE = 'codex:post_tool_use';
20
+ /** Live UserPromptSubmit payload → correction candidate (logical key mirrors the observation). */
21
+ export function buildLiveCorrectionCandidate(args) {
22
+ const { fields } = args;
23
+ if (fields.turnId === null || fields.prompt === null)
24
+ return null;
25
+ return {
26
+ kind: 'user_correction',
27
+ hostKind: 'codex',
28
+ logicalObservationKey: `codex|${args.rolloutIdentity}|${fields.turnId}|user`,
29
+ rolloutIdentity: args.rolloutIdentity,
30
+ rootSessionId: fields.sessionId,
31
+ hostTurnId: fields.turnId,
32
+ text: fields.prompt,
33
+ observedAt: new Date().toISOString(),
34
+ };
35
+ }
36
+ /** Live PostToolUse payload → tool-failure candidate (logical key mirrors the observation). */
37
+ export function buildLiveToolCandidate(args) {
38
+ const { fields } = args;
39
+ if (fields.turnId === null || fields.toolUseId === null)
40
+ return null;
41
+ return {
42
+ kind: 'tool_failure',
43
+ hostKind: 'codex',
44
+ logicalObservationKey: `codex|${args.rolloutIdentity}|${fields.toolUseId}`,
45
+ rolloutIdentity: args.rolloutIdentity,
46
+ rootSessionId: fields.sessionId,
47
+ hostTurnId: fields.turnId,
48
+ toolName: fields.toolName ?? '',
49
+ source: CODEX_POST_TOOL_USE_SOURCE,
50
+ ...(fields.toolInput !== undefined ? { toolInput: fields.toolInput } : {}),
51
+ ...(fields.toolResponse !== undefined ? { toolOutput: fields.toolResponse } : {}),
52
+ observedAt: new Date().toISOString(),
53
+ };
54
+ }
55
+ /**
56
+ * Decoded transcript observations → candidates. User turns carry visibleText;
57
+ * tool calls carry the CommandExecution facts projection. The logical keys are
58
+ * the decoded ones, so a replay of an already-admitted live event is an
59
+ * observation-level no-op (SPEC §10).
60
+ */
61
+ export function buildTranscriptCandidates(observations) {
62
+ const candidates = [];
63
+ for (const observation of observations) {
64
+ if (observation.kind === 'user_turn') {
65
+ if (observation.visibleText === undefined)
66
+ continue;
67
+ candidates.push({
68
+ kind: 'user_correction',
69
+ hostKind: 'codex',
70
+ logicalObservationKey: observation.logicalObservationKey,
71
+ rolloutIdentity: observation.rolloutIdentity,
72
+ rootSessionId: observation.rootSessionId,
73
+ hostTurnId: observation.hostTurnId,
74
+ text: observation.visibleText,
75
+ observedAt: observation.observedAt,
76
+ });
77
+ }
78
+ else if (observation.kind === 'tool_call') {
79
+ const facts = observation.toolFacts;
80
+ if (!isRecord(facts))
81
+ continue;
82
+ const toolName = own(facts, 'toolName');
83
+ const exitCode = own(facts, 'exitCode');
84
+ candidates.push({
85
+ kind: 'tool_failure',
86
+ hostKind: 'codex',
87
+ logicalObservationKey: observation.logicalObservationKey,
88
+ rolloutIdentity: observation.rolloutIdentity,
89
+ rootSessionId: observation.rootSessionId,
90
+ hostTurnId: observation.hostTurnId,
91
+ toolName: typeof toolName === 'string' ? toolName : '',
92
+ source: CODEX_POST_TOOL_USE_SOURCE,
93
+ toolInput: own(facts, 'command') ?? null,
94
+ ...(exitCode !== undefined ? { toolOutput: { exitCode, stdout: own(facts, 'stdout') ?? null, stderr: own(facts, 'stderr') ?? null } } : {}),
95
+ observedAt: observation.observedAt,
96
+ });
97
+ }
98
+ }
99
+ return candidates;
100
+ }
101
+ /**
102
+ * Run admission for the given candidates and drive every admitted pain
103
+ * through task-ensure + evidence promotion. Exactly-once is owned by the
104
+ * host-runtime seams; this orchestrator only sequences them and surfaces
105
+ * bounded degradations (duplicate / rate-limited / ordinary conversation are
106
+ * normal outcomes and produce no noise, SPEC §20/§21).
107
+ */
108
+ export async function runGovernanceAdmission(args) {
109
+ const { workspaceDir, candidates } = args;
110
+ if (candidates.length === 0)
111
+ return { degradations: [] };
112
+ const degradations = [];
113
+ const push = (reason, nextAction) => {
114
+ if (degradations.length < 4)
115
+ degradations.push({ reason: reason.slice(0, 300), nextAction: nextAction.slice(0, 300) });
116
+ };
117
+ const admitted = admitGovernanceSignals({ workspaceDir, candidates });
118
+ if (!admitted.ok) {
119
+ push(admitted.reason, admitted.nextAction);
120
+ return { degradations };
121
+ }
122
+ for (const outcome of admitted.outcomes) {
123
+ if (!('disposition' in outcome))
124
+ continue;
125
+ if (outcome.disposition !== 'admitted' && outcome.disposition !== 'already_admitted')
126
+ continue;
127
+ const cont = await ensureGovernanceContinuation({
128
+ workspaceDir,
129
+ logicalObservationKey: outcome.logicalObservationKey,
130
+ canonicalPainId: outcome.canonicalPainId,
131
+ });
132
+ if (!cont.ok) {
133
+ push(cont.reason, cont.nextAction);
134
+ }
135
+ }
136
+ return { degradations };
137
+ }
@@ -0,0 +1,41 @@
1
+ import { type CodexIngestionOutcome } from './ingestion.js';
2
+ import type { TranscriptPort } from './transcript-decoder.js';
3
+ export interface CodexCatchUpOptions {
4
+ readonly workspaceDir: string;
5
+ readonly env?: {
6
+ CODEX_HOME?: string | undefined;
7
+ };
8
+ readonly now?: Date;
9
+ readonly port?: TranscriptPort;
10
+ /** Bounded per invocation (default 8 rollouts × the 1 MiB batch bound). */
11
+ readonly maxRollouts?: number;
12
+ }
13
+ export interface CodexCatchUpRolloutResult {
14
+ readonly rolloutIdentity: string;
15
+ readonly outcome: CodexIngestionOutcome;
16
+ /** Admission degradations from this rollout's candidates (bounded by runGovernanceAdmission). */
17
+ readonly admissionDegradations: readonly {
18
+ reason: string;
19
+ nextAction: string;
20
+ }[];
21
+ }
22
+ export type CodexCatchUpResult = {
23
+ status: 'skipped';
24
+ reason: string;
25
+ nextAction: string;
26
+ } | {
27
+ status: 'ok' | 'degraded';
28
+ /** Rollouts processed this invocation (bounded). */
29
+ readonly rollouts: readonly CodexCatchUpRolloutResult[];
30
+ /** Processed rollouts that still carry lag (degraded or lagBytes > 0). */
31
+ readonly remainingLagRollouts: readonly string[];
32
+ /** Checkpoints not examined this invocation because of the bound (rotation converges them over repeated passes). */
33
+ readonly unexaminedRollouts: readonly string[];
34
+ };
35
+ export declare const CODEX_CATCH_UP_NEXT_ACTION = "Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.";
36
+ /**
37
+ * Perform one bounded catch-up pass. `remainingLagRollouts` is derived from
38
+ * each processed rollout's reported lag plus the checkpoints left unprocessed
39
+ * by the bound — callers (worker cycle / CLI) repeat passes to converge.
40
+ */
41
+ export declare function catchUpCodexIngestion(options: CodexCatchUpOptions): Promise<CodexCatchUpResult>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Codex ingestion catch-up (PRI-624 Slice C; SPEC §13/§15).
3
+ *
4
+ * Bounded non-destructive catch-up of transcript lag for rollouts the
5
+ * authenticated Workspace hook already delivered: every checkpoint in the
6
+ * workspace trajectory database names a rollout that once arrived with an
7
+ * authenticated transcript_path. For each (bounded) checkpoint we resolve the
8
+ * rollout's transcript by EXACT uuid (never a session scan), re-authorize the
9
+ * path through the same validator the hook uses, run the same bounded
10
+ * incremental delta from the checkpoint, and feed any admission candidates
11
+ * through the same Slice B admission/continuation pass the hook runs.
12
+ *
13
+ * Gated by `codex_conversation_ingestion`: flag-off performs ZERO transcript
14
+ * filesystem I/O — no checkpoint-to-path resolution, no sessions-tree lookup,
15
+ * no reads (SPEC §10 hard privacy invariant; the port spy test covers the
16
+ * lookup path too).
17
+ */
18
+ import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
19
+ import { loadPdConfigForPlugin, listGovernanceCheckpoints } from '@principles/host-runtime';
20
+ import { resolveCodexHome } from './codex-home.js';
21
+ import { locateCodexTranscriptByRolloutIdentity } from './transcript-locate.js';
22
+ import { ingestCodexTranscriptFromPath } from './ingestion.js';
23
+ import { runGovernanceAdmission } from './admission.js';
24
+ const DEFAULT_MAX_ROLLOUTS = 8;
25
+ export const CODEX_CATCH_UP_NEXT_ACTION = 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.';
26
+ /**
27
+ * Perform one bounded catch-up pass. `remainingLagRollouts` is derived from
28
+ * each processed rollout's reported lag plus the checkpoints left unprocessed
29
+ * by the bound — callers (worker cycle / CLI) repeat passes to converge.
30
+ */
31
+ export async function catchUpCodexIngestion(options) {
32
+ const { workspaceDir } = options;
33
+ // Flag gate FIRST — before resolveCodexHome, checkpoint listing, any FS I/O.
34
+ const config = loadPdConfigForPlugin(workspaceDir);
35
+ if (!config.ok) {
36
+ const [first] = config.errors;
37
+ return { status: 'skipped', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
38
+ }
39
+ const { flags } = computeFeatureFlagsFromConfig(config.effective);
40
+ if (flags['host.codex']?.enabled !== true) {
41
+ return { status: 'skipped', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the selected Workspace to enable PD.' };
42
+ }
43
+ if (flags.codex_conversation_ingestion?.enabled !== true) {
44
+ return { status: 'skipped', reason: 'feature_disabled', nextAction: CODEX_CATCH_UP_NEXT_ACTION };
45
+ }
46
+ const home = resolveCodexHome(options.env);
47
+ if (!home.ok)
48
+ return { status: 'skipped', reason: home.reason, nextAction: home.nextAction };
49
+ const listed = listGovernanceCheckpoints({ workspaceDir, hostKind: 'codex' });
50
+ if (!listed.ok)
51
+ return { status: 'skipped', reason: listed.reason, nextAction: listed.nextAction };
52
+ const maxRollouts = Math.max(1, Math.min(options.maxRollouts ?? DEFAULT_MAX_ROLLOUTS, 32));
53
+ const checkpoints = listed.checkpoints.slice(0, maxRollouts);
54
+ const unprocessed = listed.checkpoints.slice(maxRollouts);
55
+ const rollouts = [];
56
+ const remainingLagRollouts = [];
57
+ for (const checkpoint of checkpoints) {
58
+ const located = locateCodexTranscriptByRolloutIdentity(home.home, checkpoint.rolloutIdentity);
59
+ if (!located.ok) {
60
+ remainingLagRollouts.push(checkpoint.rolloutIdentity);
61
+ rollouts.push({
62
+ rolloutIdentity: checkpoint.rolloutIdentity,
63
+ outcome: { status: 'degraded', reason: located.reason, nextAction: located.nextAction, warnings: [] },
64
+ admissionDegradations: [],
65
+ });
66
+ continue;
67
+ }
68
+ const outcome = ingestCodexTranscriptFromPath({
69
+ workspaceDir,
70
+ transcriptPath: located.transcriptPath,
71
+ fallbackRootSessionId: checkpoint.rootSessionId,
72
+ env: options.env,
73
+ now: options.now,
74
+ port: options.port,
75
+ });
76
+ let admissionDegradations = [];
77
+ if (outcome.status === 'ok' && outcome.admissionCandidates.length > 0) {
78
+ const admission = await runGovernanceAdmission({ workspaceDir, candidates: outcome.admissionCandidates });
79
+ admissionDegradations = [...admission.degradations];
80
+ }
81
+ if (outcome.status !== 'ok' || outcome.lagBytes > 0) {
82
+ remainingLagRollouts.push(checkpoint.rolloutIdentity);
83
+ }
84
+ rollouts.push({ rolloutIdentity: checkpoint.rolloutIdentity, outcome, admissionDegradations });
85
+ }
86
+ const unexaminedRollouts = unprocessed.map((leftover) => leftover.rolloutIdentity);
87
+ const anyDegraded = rollouts.some((entry) => entry.outcome.status === 'degraded' || entry.admissionDegradations.length > 0);
88
+ return {
89
+ status: anyDegraded ? 'degraded' : 'ok',
90
+ rollouts,
91
+ remainingLagRollouts,
92
+ unexaminedRollouts,
93
+ };
94
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Codex hook payload field extraction (Slice A) — shared by the live
3
+ * observation ingestor and the Slice B admission orchestrator.
4
+ */
5
+ export interface PayloadFields {
6
+ transcriptPath: string | null;
7
+ sessionId: string;
8
+ turnId: string | null;
9
+ prompt: string | null;
10
+ toolUseId: string | null;
11
+ toolName: string | null;
12
+ toolInput: unknown;
13
+ toolResponse: unknown;
14
+ }
15
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
16
+ export declare function own(value: Record<string, unknown>, key: string): unknown;
17
+ export declare function extractFields(raw: unknown): PayloadFields | null;
@@ -0,0 +1,28 @@
1
+ export function isRecord(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ export function own(value, key) {
5
+ return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
6
+ }
7
+ export function extractFields(raw) {
8
+ if (!isRecord(raw))
9
+ return null;
10
+ const transcriptPath = own(raw, 'transcript_path');
11
+ if (transcriptPath !== null && typeof transcriptPath !== 'string')
12
+ return null;
13
+ const sessionId = own(raw, 'session_id');
14
+ const turnId = own(raw, 'turn_id');
15
+ const prompt = own(raw, 'prompt');
16
+ const toolUseId = own(raw, 'tool_use_id');
17
+ const toolName = own(raw, 'tool_name');
18
+ return {
19
+ transcriptPath: transcriptPath ?? null,
20
+ sessionId: typeof sessionId === 'string' ? sessionId : '',
21
+ turnId: typeof turnId === 'string' ? turnId : null,
22
+ prompt: typeof prompt === 'string' ? prompt : null,
23
+ toolUseId: typeof toolUseId === 'string' ? toolUseId : null,
24
+ toolName: typeof toolName === 'string' ? toolName : null,
25
+ toolInput: own(raw, 'tool_input'),
26
+ toolResponse: own(raw, 'tool_response'),
27
+ };
28
+ }
@@ -19,6 +19,7 @@
19
19
  * the transcript replay later converges with (SPEC §10 source precedence).
20
20
  */
21
21
  import type { HostEventKind } from '@principles/core/host';
22
+ import { type GovernanceSignalCandidate } from '@principles/host-runtime';
22
23
  import { type TranscriptPort } from './transcript-decoder.js';
23
24
  export interface CodexIngestionOptions {
24
25
  readonly workspaceDir: string;
@@ -35,6 +36,9 @@ export type CodexIngestionOutcome = {
35
36
  duplicates: number;
36
37
  warnings: readonly string[];
37
38
  lagBytes: number;
39
+ /** Slice B: normalized admission candidates for the committed observations (host-neutral; consumed by pd-hook's admission pass). */
40
+ readonly admissionCandidates: readonly GovernanceSignalCandidate[];
41
+ readonly rolloutIdentity: string;
38
42
  } | {
39
43
  status: 'degraded';
40
44
  reason: string;
@@ -44,3 +48,24 @@ export type CodexIngestionOutcome = {
44
48
  /** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
45
49
  export declare function setCodexTranscriptPortForTest(port: TranscriptPort | null): void;
46
50
  export declare function ingestCodexConversation(rawPayload: unknown, kind: HostEventKind, options: CodexIngestionOptions): CodexIngestionOutcome;
51
+ export interface CodexTranscriptFromPathArgs {
52
+ readonly workspaceDir: string;
53
+ /** Absolute transcript path (catch-up resolves it by exact rollout identity before calling). */
54
+ readonly transcriptPath: string;
55
+ /** Fallback root session when neither the transcript nor the checkpoint carries one. */
56
+ readonly fallbackRootSessionId: string;
57
+ readonly env?: {
58
+ CODEX_HOME?: string | undefined;
59
+ };
60
+ readonly now?: Date;
61
+ readonly port?: TranscriptPort;
62
+ }
63
+ /**
64
+ * PRI-624 (Slice C): bounded incremental ingest of one known rollout from its
65
+ * durable checkpoint, driven by an explicit transcript path instead of a live
66
+ * hook payload. The path is re-authorized through the same
67
+ * `validateCodexTranscriptPath` containment + post-open identity contract the
68
+ * hook path uses (SPEC §9) — callers MUST only pass paths resolved from
69
+ * previously-authenticated rollouts (checkpoints), never discovered sessions.
70
+ */
71
+ export declare function ingestCodexTranscriptFromPath(args: CodexTranscriptFromPathArgs): CodexIngestionOutcome;
@@ -3,6 +3,8 @@ import { resolveCodexHome } from './codex-home.js';
3
3
  import { validateCodexTranscriptPath } from './transcript-path.js';
4
4
  import { classifyCodexVersion, CODEX_VERSION_NEXT_ACTION } from './codex-version.js';
5
5
  import { decodeTranscriptWindow, createNodeTranscriptPort, CODEX_INGESTION_MAX_BATCH_BYTES, TranscriptReplacedError } from './transcript-decoder.js';
6
+ import { extractFields } from './ingestion-fields.js';
7
+ import { buildLiveCorrectionCandidate, buildLiveToolCandidate, buildTranscriptCandidates } from './admission.js';
6
8
  let activePort = null;
7
9
  /** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
8
10
  export function setCodexTranscriptPortForTest(port) {
@@ -11,34 +13,6 @@ export function setCodexTranscriptPortForTest(port) {
11
13
  function activeTranscriptPort(options) {
12
14
  return options.port ?? activePort ?? createNodeTranscriptPort();
13
15
  }
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
16
  function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
43
17
  const nowIso = now.toISOString();
44
18
  let observation = null;
@@ -85,9 +59,18 @@ function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
85
59
  });
86
60
  if (!result.ok)
87
61
  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 };
62
+ // Slice B: hand the live observation to the admission pass as a normalized
63
+ // candidate (SPEC §12) — the caller (pd-hook) runs detection/admission.
64
+ const candidate = observation.kind === 'user_turn'
65
+ ? buildLiveCorrectionCandidate({ fields, rolloutIdentity })
66
+ : buildLiveToolCandidate({ fields, rolloutIdentity });
67
+ return {
68
+ status: 'ok', inserted: result.inserted, enriched: result.enriched, duplicates: result.duplicates, warnings: result.warnings, lagBytes: 0,
69
+ admissionCandidates: candidate !== null ? [candidate] : [],
70
+ rolloutIdentity,
71
+ };
89
72
  }
90
- function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentity, workspaceDir, now, port }) {
73
+ function ingestTranscriptDelta({ fallbackRootSessionId, canonicalPath, identity, rolloutIdentity, workspaceDir, now, port }) {
91
74
  const checkpoint = readGovernanceCheckpoint({ workspaceDir, hostKind: 'codex', rolloutIdentity });
92
75
  if (checkpoint !== null && !('byteOffset' in checkpoint) && 'ok' in checkpoint && checkpoint.ok === false) {
93
76
  return { status: 'degraded', reason: checkpoint.reason, nextAction: checkpoint.nextAction, warnings: [] };
@@ -118,7 +101,7 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
118
101
  fileOffset: offset,
119
102
  byteBoundReached,
120
103
  rolloutIdentity,
121
- fallbackRootSessionId: existing !== null ? existing.rootSessionId : fields.sessionId,
104
+ fallbackRootSessionId: existing !== null ? existing.rootSessionId : fallbackRootSessionId,
122
105
  nowIso: now.toISOString(),
123
106
  });
124
107
  // Supported-version guard (SPEC §9): the version signal lives in the
@@ -142,7 +125,7 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
142
125
  else if (decoded.stop.kind === 'oversized_record') {
143
126
  degradations.push({ reason: 'transcript_record_too_large', nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.' });
144
127
  }
145
- const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ?? fields.sessionId;
128
+ const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ?? fallbackRootSessionId;
146
129
  const result = ingestGovernanceObservations({
147
130
  workspaceDir,
148
131
  rollout: {
@@ -200,6 +183,11 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
200
183
  duplicates: result.duplicates,
201
184
  warnings,
202
185
  lagBytes: Math.max(0, window.fileSize - decoded.nextByteOffset),
186
+ // Slice B: transcript-only user turns / tool calls that were never seen
187
+ // live get their admission evaluation here (SPEC §10/§12) — already
188
+ // admitted logical keys no-op on the marker.
189
+ admissionCandidates: buildTranscriptCandidates(decoded.observations),
190
+ rolloutIdentity,
203
191
  };
204
192
  }
205
193
  export function ingestCodexConversation(rawPayload, kind, options) {
@@ -222,7 +210,7 @@ export function ingestCodexConversation(rawPayload, kind, options) {
222
210
  return ingestLiveObservation({ fields, rolloutIdentity: validated.rolloutIdentity, workspaceDir: options.workspaceDir, now });
223
211
  }
224
212
  return ingestTranscriptDelta({
225
- fields,
213
+ fallbackRootSessionId: fields.sessionId,
226
214
  canonicalPath: validated.canonicalPath,
227
215
  identity: validated.identity,
228
216
  rolloutIdentity: validated.rolloutIdentity,
@@ -232,3 +220,29 @@ export function ingestCodexConversation(rawPayload, kind, options) {
232
220
  port: activeTranscriptPort(options),
233
221
  });
234
222
  }
223
+ /**
224
+ * PRI-624 (Slice C): bounded incremental ingest of one known rollout from its
225
+ * durable checkpoint, driven by an explicit transcript path instead of a live
226
+ * hook payload. The path is re-authorized through the same
227
+ * `validateCodexTranscriptPath` containment + post-open identity contract the
228
+ * hook path uses (SPEC §9) — callers MUST only pass paths resolved from
229
+ * previously-authenticated rollouts (checkpoints), never discovered sessions.
230
+ */
231
+ export function ingestCodexTranscriptFromPath(args) {
232
+ const home = resolveCodexHome(args.env);
233
+ if (!home.ok)
234
+ return { status: 'degraded', reason: home.reason, nextAction: home.nextAction, warnings: [] };
235
+ const validated = validateCodexTranscriptPath(args.transcriptPath, home.home);
236
+ if (!validated.ok)
237
+ return { status: 'degraded', reason: validated.reason, nextAction: validated.nextAction, warnings: [] };
238
+ return ingestTranscriptDelta({
239
+ fallbackRootSessionId: args.fallbackRootSessionId,
240
+ canonicalPath: validated.canonicalPath,
241
+ identity: validated.identity,
242
+ rolloutIdentity: validated.rolloutIdentity,
243
+ workspaceDir: args.workspaceDir,
244
+ env: args.env ?? {},
245
+ now: args.now ?? new Date(),
246
+ port: args.port ?? activeTranscriptPort({ workspaceDir: args.workspaceDir }),
247
+ });
248
+ }
@@ -0,0 +1,13 @@
1
+ export type CodexTranscriptLookup = {
2
+ ok: true;
3
+ transcriptPath: string;
4
+ } | {
5
+ ok: false;
6
+ reason: 'catch_up_rollout_identity_invalid' | 'catch_up_sessions_root_missing' | 'catch_up_transcript_missing' | 'catch_up_transcript_ambiguous' | 'catch_up_lookup_exhausted';
7
+ nextAction: string;
8
+ };
9
+ /**
10
+ * Resolve one previously-authenticated rollout identity to its transcript
11
+ * path by exact-uuid filename match under `<codexHome>/sessions`.
12
+ */
13
+ export declare function locateCodexTranscriptByRolloutIdentity(codexHome: string, rolloutIdentity: string): CodexTranscriptLookup;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Codex transcript locator for catch-up (PRI-624 Slice C).
3
+ *
4
+ * The durable checkpoint stores only the rollout uuid (SPEC §18 scenario 9
5
+ * forbids raw paths in the DB), so catch-up must resolve a checkpointed
6
+ * rollout back to its transcript file. This is NOT session discovery: the
7
+ * lookup searches for the EXACT rollout uuid of a rollout the authenticated
8
+ * Workspace hook previously delivered (only hooks write checkpoints). It
9
+ * never guesses a "latest session", never returns a partial match, and
10
+ * refuses ambiguities (ADR-0020 §11.2 / SPEC §9).
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { parseRolloutFileName } from './transcript-path.js';
15
+ /** Bounded walk: hard cap on visited directory entries so a pathological sessions tree cannot stall the worker. */
16
+ const MAX_LOOKUP_ENTRIES = 5000;
17
+ const ROLLOUT_IDENTITY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
+ /**
19
+ * Resolve one previously-authenticated rollout identity to its transcript
20
+ * path by exact-uuid filename match under `<codexHome>/sessions`.
21
+ */
22
+ export function locateCodexTranscriptByRolloutIdentity(codexHome, rolloutIdentity) {
23
+ if (!ROLLOUT_IDENTITY_PATTERN.test(rolloutIdentity)) {
24
+ return { ok: false, reason: 'catch_up_rollout_identity_invalid', nextAction: 'the checkpointed rollout identity is not a rollout uuid; inspect the workspace trajectory database.' };
25
+ }
26
+ const sessionsRoot = path.join(codexHome, 'sessions');
27
+ let rootStats;
28
+ try {
29
+ rootStats = fs.statSync(sessionsRoot);
30
+ }
31
+ catch {
32
+ return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME has no sessions root; verify the Codex home used by the hook and by catch-up matches.' };
33
+ }
34
+ if (!rootStats.isDirectory()) {
35
+ return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME sessions path is not a directory; verify the Codex home configuration.' };
36
+ }
37
+ const matches = [];
38
+ let visited = 0;
39
+ const stack = [sessionsRoot];
40
+ while (stack.length > 0 && matches.length < 2) {
41
+ const dir = stack.pop();
42
+ if (dir === undefined)
43
+ break;
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ }
48
+ catch {
49
+ continue; // unreadable subtree — other subtrees may still hold the rollout
50
+ }
51
+ for (const entry of entries) {
52
+ visited += 1;
53
+ if (visited > MAX_LOOKUP_ENTRIES) {
54
+ return { ok: false, reason: 'catch_up_lookup_exhausted', nextAction: 'the sessions tree exceeded the bounded catch-up lookup; keep CODEX_HOME/sessions pruned or catch up rollouts manually.' };
55
+ }
56
+ if (entry.isDirectory()) {
57
+ stack.push(path.join(dir, entry.name));
58
+ }
59
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
60
+ if (parseRolloutFileName(entry.name) === rolloutIdentity) {
61
+ matches.push(path.join(dir, entry.name));
62
+ if (matches.length >= 2)
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ if (matches.length === 0) {
69
+ return { ok: false, reason: 'catch_up_transcript_missing', nextAction: 'the checkpointed rollout has no transcript under the Codex sessions root (rotated or cleaned by Codex); its committed observations remain, the pending lag cannot be recovered.' };
70
+ }
71
+ if (matches.length > 1) {
72
+ return { ok: false, reason: 'catch_up_transcript_ambiguous', nextAction: 'multiple transcripts match the rollout identity; refuse to guess — inspect the Codex sessions tree.' };
73
+ }
74
+ return { ok: true, transcriptPath: matches[0] };
75
+ }
@@ -20,4 +20,6 @@ export type TranscriptPathValidation = {
20
20
  reason: 'transcript_path_invalid' | 'transcript_path_outside_codex_home';
21
21
  nextAction: string;
22
22
  };
23
+ /** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
24
+ export declare function parseRolloutFileName(fileName: string): string | null;
23
25
  export declare function validateCodexTranscriptPath(transcriptPath: string, codexHome: string): TranscriptPathValidation;
@@ -21,7 +21,7 @@ function isHex(value) {
21
21
  return UUID_HEX.test(value);
22
22
  }
23
23
  /** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
24
- function parseRolloutFileName(fileName) {
24
+ export function parseRolloutFileName(fileName) {
25
25
  if (!fileName.startsWith('rollout-') || !fileName.endsWith('.jsonl'))
26
26
  return null;
27
27
  const stem = fileName.slice('rollout-'.length, -'.jsonl'.length);
package/dist/pd-hook.js CHANGED
@@ -7,6 +7,7 @@ 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
9
  import { ingestCodexConversation } from './ingestion/ingestion.js';
10
+ import { runGovernanceAdmission } from './ingestion/admission.js';
10
11
  const MAX_DIAGNOSTIC = 500;
11
12
  function diagnostic(reason, nextAction) {
12
13
  const boundedReason = reason.replace(/\s+/g, ' ').trim().slice(0, MAX_DIAGNOSTIC);
@@ -17,11 +18,16 @@ function errorMessage(error) {
17
18
  return error instanceof Error ? error.message.slice(0, MAX_DIAGNOSTIC) : 'unknown_error';
18
19
  }
19
20
  // Bounded governance-observation ingestion (Codex Governance Closure Slice
20
- // A). Runs only when BOTH host.codex and codex_conversation_ingestion are
21
+ // A) followed by the Slice B signal-admission pass (SPEC §12/§13): detection
22
+ // → canonical pain → evidence promotion → one pending Diagnostician task.
23
+ // Runs only when BOTH host.codex and codex_conversation_ingestion are
21
24
  // enabled — the flag gate below happens BEFORE any transcript path
22
25
  // 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) {
26
+ // receives zero calls (SPEC §10 hard privacy invariant). Admission runs
27
+ // BEFORE dispatch so a live tool failure is admitted through the same
28
+ // canonical derivation first and the production handler's duplicate probe
29
+ // then converges to a no-op (exactly one pain per real tool call).
30
+ async function runConversationIngestion(args) {
25
31
  const { rawPayload, kind, workspaceDir, env } = args;
26
32
  if (kind !== 'turn_complete' && kind !== 'before_prompt_build' && kind !== 'after_tool_call')
27
33
  return [];
@@ -35,6 +41,16 @@ function runConversationIngestion(args) {
35
41
  for (const warning of outcome.warnings.slice(0, 2)) {
36
42
  diagnostics.push(diagnostic(warning, 'Inspect PD Workspace governance-observation state; ingestion continued.'));
37
43
  }
44
+ // Slice B admission: hook awaits only admission + durable enqueue —
45
+ // never an LLM (SPEC §12/§13). Ordinary conversation returns no
46
+ // candidates/no admissions and stays completely silent.
47
+ const admission = await runGovernanceAdmission({
48
+ workspaceDir,
49
+ candidates: outcome.admissionCandidates,
50
+ });
51
+ for (const degradation of admission.degradations.slice(0, 2)) {
52
+ diagnostics.push(diagnostic(degradation.reason, degradation.nextAction));
53
+ }
38
54
  }
39
55
  }
40
56
  catch (error) {
@@ -87,7 +103,7 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
87
103
  // turn on stderr — never stdout, and never on the per-tool events (that
88
104
  // would be per-event noise).
89
105
  const stderr = ingestionEnabled
90
- ? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
106
+ ? await runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
91
107
  : [diagnostic('feature_disabled', 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.')];
92
108
  return { stdout: {}, exitCode: 0, stderr };
93
109
  }
@@ -99,7 +115,7 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
99
115
  return { stdout: adapter.encodeOutput({ decision: 'allow', source: event.source }, 'session_start'), exitCode: 0, stderr: [] };
100
116
  }
101
117
  const ingestionDiagnostics = ingestionEnabled
102
- ? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
118
+ ? await runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
103
119
  : [];
104
120
  const result = await createProductionHostRuntime().dispatch(event);
105
121
  const stderr = [...(result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.')), ...ingestionDiagnostics];
@@ -0,0 +1,57 @@
1
+ import { type InternalizationConsumerCycleOutcome, type ConsumerCycleLogger, type ReconcileGovernanceContinuationResult } from '@principles/host-runtime';
2
+ import { type CodexCatchUpResult } from '../ingestion/catch-up.js';
3
+ import type { TranscriptPort } from '../ingestion/transcript-decoder.js';
4
+ export type CodexWorkerMode = 'ready' | 'manual_action_required' | 'paused' | 'degraded';
5
+ export interface CodexWorkerCycleStepReport {
6
+ readonly catchUp: CodexCatchUpResult;
7
+ readonly reconcile: ReconcileGovernanceContinuationResult;
8
+ /** At most ONE diagnostician execution per cycle (bounded work). */
9
+ readonly diagnostician: {
10
+ readonly taskId: string;
11
+ readonly status: 'succeeded' | 'failed' | 'retried' | 'skipped' | 'degraded';
12
+ readonly message?: string;
13
+ readonly errorCategory?: string;
14
+ } | null;
15
+ readonly downstream: InternalizationConsumerCycleOutcome | null;
16
+ }
17
+ export interface CodexWorkerCycleResult {
18
+ readonly workspaceDir: string;
19
+ readonly mode: CodexWorkerMode;
20
+ readonly reason?: string;
21
+ readonly nextAction?: string;
22
+ readonly report?: CodexWorkerCycleStepReport;
23
+ }
24
+ export interface CodexWorkerCycleOptions {
25
+ readonly workspaceDir: string;
26
+ readonly env?: {
27
+ CODEX_HOME?: string | undefined;
28
+ };
29
+ readonly now?: Date;
30
+ readonly logger?: ConsumerCycleLogger;
31
+ readonly emitEvent?: (event: string, payloadJson: string) => void;
32
+ readonly port?: TranscriptPort;
33
+ /** Cap on diagnostician candidates inspected per cycle (default 5). */
34
+ readonly diagnosticianCandidateLimit?: number;
35
+ }
36
+ /**
37
+ * Run ONE bounded worker cycle for a canonical workspace. Never throws:
38
+ * every failure becomes a structured degraded result with a nextAction.
39
+ */
40
+ export declare function runCodexWorkspaceWorkerCycle(options: CodexWorkerCycleOptions): Promise<CodexWorkerCycleResult>;
41
+ export interface CodexWorkerStatusEvaluation {
42
+ readonly mode: CodexWorkerMode;
43
+ readonly reason?: string;
44
+ readonly nextAction?: string;
45
+ }
46
+ /**
47
+ * SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
48
+ * LLM, no transcript I/O). `manual_action_required` means no
49
+ * Companion-registered worker serves this workspace — the manual CLI path
50
+ * (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
51
+ * "an automatic worker would run and hold the workspace task leases";
52
+ * live-worker liveness surfacing belongs to the Slice D health surface.
53
+ */
54
+ export declare function computeCodexWorkerStatusMode(input: {
55
+ workspaceDir: string;
56
+ registeredInInstallManifest: boolean;
57
+ }): CodexWorkerStatusEvaluation;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Codex Workspace worker cycle (PRI-624 Slice C; SPEC §13/§15; ADR-0020 §11.1).
3
+ *
4
+ * ONE Workspace-scoped worker cycle with exactly three background
5
+ * responsibilities (the Owner-approved MVP exception — NOT a general daemon):
6
+ *
7
+ * 1. catch up transcript lag (gated by codex_conversation_ingestion)
8
+ * 2. run the Slice B idempotent reconciliation pass
9
+ * 3. lease + run one Diagnostician task, then ONE bounded downstream
10
+ * consumer cycle through the SHARED host-neutral executor
11
+ * (internalization_auto_consumer = workspace execution authority)
12
+ *
13
+ * Every step reuses existing authority: catch-up reuses the Slice A/B
14
+ * ingestion + admission seams, reconciliation reuses
15
+ * `reconcileGovernanceContinuation`, the diagnostician reuses the
16
+ * PainSignalBridge lease/runner contract, downstream reuses the same
17
+ * `runInternalizationConsumerCycle` the OpenClaw auto-consumer runs. No new
18
+ * task store, no private retry queue, no second scheduler state.
19
+ *
20
+ * Correctness under concurrent consumers (OpenClaw auto-consumer + this
21
+ * worker on one workspace) is owned by the durable Runtime V2 task lease —
22
+ * never by process-local state.
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { computeFeatureFlagsFromConfig, createRuntimeStateHandle, createPainSignalBridge, isRetryWaitBackoffElapsed, PrincipleTreeLedgerAdapter, } from '@principles/core/runtime-v2';
27
+ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig, reconcileGovernanceContinuation, runInternalizationConsumerCycle, } from '@principles/host-runtime';
28
+ import { catchUpCodexIngestion } from '../ingestion/catch-up.js';
29
+ const WORKER_OWNER = 'companion-worker';
30
+ const DEFAULT_DIAG_CANDIDATE_LIMIT = 5;
31
+ function workerLogger(logger) {
32
+ return logger ?? {
33
+ info: () => undefined,
34
+ warn: () => undefined,
35
+ error: () => undefined,
36
+ };
37
+ }
38
+ function directoryExists(dir) {
39
+ try {
40
+ return fs.statSync(dir).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Oldest pending diagnostician first; retry_wait candidates are filtered to
48
+ * those whose backoff window has ELAPSED, so an old-but-still-waiting task
49
+ * cannot starve a younger eligible one (review P1). The candidate set stays
50
+ * bounded (`limit`), and executePendingDiagnosis re-enforces the backoff
51
+ * window on the chosen task — no double bookkeeping.
52
+ */
53
+ async function pickDiagnosticianCandidate(stateManager, limit) {
54
+ const pending = await stateManager.listTasks({ taskKind: 'diagnostician', status: 'pending', orderBy: 'updated_at_asc', limit });
55
+ const [first] = pending;
56
+ if (first !== undefined)
57
+ return first;
58
+ const retrying = await stateManager.listTasks({ taskKind: 'diagnostician', status: 'retry_wait', orderBy: 'updated_at_asc', limit });
59
+ for (const candidate of retrying) {
60
+ if (isRetryWaitBackoffElapsed(candidate.status, candidate.leaseExpiresAt)) {
61
+ return candidate;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * Run ONE bounded worker cycle for a canonical workspace. Never throws:
68
+ * every failure becomes a structured degraded result with a nextAction.
69
+ */
70
+ export async function runCodexWorkspaceWorkerCycle(options) {
71
+ const workspaceDir = path.resolve(options.workspaceDir);
72
+ const logger = workerLogger(options.logger);
73
+ const emitEvent = options.emitEvent ?? ((_event, _payload) => undefined);
74
+ const base = { workspaceDir };
75
+ // Step 1 — Workspace eligibility.
76
+ if (!directoryExists(workspaceDir)) {
77
+ return { ...base, mode: 'degraded', reason: 'workspace_missing', nextAction: 'The workspace directory no longer exists; remove it from the install manifest or restore it. No PD state was mutated.' };
78
+ }
79
+ const config = loadPdConfigForPlugin(workspaceDir);
80
+ if (!config.ok) {
81
+ const [first] = config.errors;
82
+ return { ...base, mode: 'degraded', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
83
+ }
84
+ const { flags } = computeFeatureFlagsFromConfig(config.effective);
85
+ if (flags['host.codex']?.enabled !== true) {
86
+ return { ...base, mode: 'paused', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the Workspace .pd/config.yaml to enable Codex PD behavior.' };
87
+ }
88
+ const consumerFlag = loadFeatureFlagFromConfig(workspaceDir, 'internalization_auto_consumer', { info: (m) => logger.info(m), warn: (m) => logger.warn(m) });
89
+ // Step 2 — Catch-up transcript lag (SPEC §13: gated by the ingestion flag,
90
+ // NOT by the consumer flag; catchUpCodexIngestion re-checks and returns a
91
+ // zero-I/O skip when ingestion is off).
92
+ const catchUp = await catchUpCodexIngestion({
93
+ workspaceDir,
94
+ env: options.env,
95
+ now: options.now,
96
+ port: options.port,
97
+ });
98
+ // Step 3 — Slice B idempotent reconciliation (always runs; creates no LLM work).
99
+ const reconcile = await reconcileGovernanceContinuation({ workspaceDir });
100
+ // Steps 4–6 — execution authority: internalization_auto_consumer.
101
+ let diagnostician = null;
102
+ if (!consumerFlag.enabled) {
103
+ // paused: execution pause, NOT evidence freeze. Catch-up + reconcile
104
+ // already ran above; manual CLI remains allowed (SPEC §13).
105
+ return {
106
+ ...base,
107
+ mode: 'paused',
108
+ reason: 'internalization_auto_consumer_disabled',
109
+ nextAction: 'Automatic internalization execution is paused for this Workspace; manual commands remain available: pd diagnose, pd runtime internalization run-once.',
110
+ report: { catchUp, reconcile, diagnostician: null, downstream: null },
111
+ };
112
+ }
113
+ // Step 4 — expired-lease recovery sweep, then Step 5/6: at most one
114
+ // Diagnostician task via the existing bridge lease/runner contract.
115
+ try {
116
+ const handle = await createRuntimeStateHandle({ workspaceDir, readonly: false });
117
+ try {
118
+ const sweep = await handle.stateManager.runRecoverySweep();
119
+ if (sweep.recovered > 0 || sweep.errors.length > 0) {
120
+ emitEvent('CODEX_WORKER_RECOVERY_SWEEP', JSON.stringify({ recovered: sweep.recovered, failed: sweep.errors.length }));
121
+ }
122
+ const candidate = await pickDiagnosticianCandidate(handle.stateManager, options.diagnosticianCandidateLimit ?? DEFAULT_DIAG_CANDIDATE_LIMIT);
123
+ if (candidate !== null) {
124
+ const stateDir = path.join(workspaceDir, '.state');
125
+ const bridge = await createPainSignalBridge({
126
+ workspaceDir,
127
+ stateDir,
128
+ ledgerAdapter: new PrincipleTreeLedgerAdapter({ stateDir }),
129
+ owner: WORKER_OWNER,
130
+ effectiveConfig: config.effective,
131
+ getEnvVar: (name) => process.env[name],
132
+ });
133
+ const executed = await bridge.executePendingDiagnosis({ taskId: candidate.taskId });
134
+ diagnostician = {
135
+ taskId: candidate.taskId,
136
+ status: executed.status,
137
+ ...(executed.message !== undefined ? { message: executed.message.slice(0, 200) } : {}),
138
+ ...(executed.errorCategory !== undefined ? { errorCategory: executed.errorCategory } : {}),
139
+ };
140
+ // The bridge stays cached per workspace (bounded: one per workspace
141
+ // per worker process, exactly like the OpenClaw plugin host) — no
142
+ // per-cycle dispose, so a concurrent cycle can never hit a disposed
143
+ // bridge. The factory self-disposes losing concurrent constructions.
144
+ }
145
+ else {
146
+ // No eligible candidate, but a retry_wait task may still be inside
147
+ // its backoff window — report it so the cycle is observable instead
148
+ // of looking like "nothing pending" (review P1: head-of-line).
149
+ const waiting = await handle.stateManager.listTasks({ taskKind: 'diagnostician', status: 'retry_wait', orderBy: 'updated_at_asc', limit: 1 });
150
+ const [oldestWaiting] = waiting;
151
+ if (oldestWaiting !== undefined && !isRetryWaitBackoffElapsed(oldestWaiting.status, oldestWaiting.leaseExpiresAt)) {
152
+ diagnostician = { taskId: oldestWaiting.taskId, status: 'skipped', message: 'retry_wait_pending' };
153
+ }
154
+ }
155
+ }
156
+ finally {
157
+ await handle.close().catch(() => undefined);
158
+ }
159
+ }
160
+ catch (error) {
161
+ const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
162
+ return {
163
+ ...base,
164
+ mode: 'degraded',
165
+ reason: `diagnostician_execution_failed:${detail}`,
166
+ nextAction: 'Inspect the Workspace runtime profile and provider configuration; the task keeps its pending/retry state and no evidence was mutated.',
167
+ report: { catchUp, reconcile, diagnostician: null, downstream: null },
168
+ };
169
+ }
170
+ // Step 7 — ONE bounded downstream consumer cycle via the shared executor
171
+ // (same implementation the OpenClaw auto-consumer runs).
172
+ const downstream = await runInternalizationConsumerCycle(workspaceDir, {
173
+ owner: WORKER_OWNER,
174
+ logLabel: 'CodexWorker',
175
+ logger,
176
+ emitEvent,
177
+ // No hostToolCatalog: PD has not declared a Codex tool catalog; a wrong
178
+ // (OpenClaw) catalog would be worse than none (PRI-630 follow-up).
179
+ });
180
+ // Aggregated mode: degraded overrides all (review P1). Any component
181
+ // degrading — catch-up, reconciliation, diagnostician or downstream —
182
+ // surfaces the workspace as degraded, with the highest-priority reason.
183
+ // A lease_conflict diagnostician failure is contention, not degradation:
184
+ // another consumer owns the task, which is normal operation.
185
+ const degradedReason = catchUp.status === 'degraded' ? `catch_up:${catchUp.remainingLagRollouts[0] ?? 'unknown'}`
186
+ : !reconcile.ok ? `reconcile:${reconcile.reason ?? 'failed'}`
187
+ : diagnostician?.status === 'failed' && diagnostician.errorCategory !== 'lease_conflict' ? `diagnostician_failed:${diagnostician.message ?? 'max_attempts_exceeded'}`
188
+ : diagnostician?.status === 'degraded' ? `diagnostician_degraded:${diagnostician.message ?? 'unknown'}`
189
+ : downstream.skipReason === 'runtime_config_error' || downstream.skipReason === 'cycle_error' || downstream.skipReason === 'config_malformed' ? `downstream:${downstream.skipReason}`
190
+ : undefined;
191
+ return {
192
+ ...base,
193
+ mode: degradedReason !== undefined ? 'degraded' : 'ready',
194
+ ...(degradedReason !== undefined ? { reason: degradedReason, nextAction: 'Inspect the Workspace .pd/config.yaml runtime profile and the per-step report above; the worker retries automatically on the next cycle.' } : {}),
195
+ report: { catchUp, reconcile, diagnostician, downstream },
196
+ };
197
+ }
198
+ /**
199
+ * SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
200
+ * LLM, no transcript I/O). `manual_action_required` means no
201
+ * Companion-registered worker serves this workspace — the manual CLI path
202
+ * (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
203
+ * "an automatic worker would run and hold the workspace task leases";
204
+ * live-worker liveness surfacing belongs to the Slice D health surface.
205
+ */
206
+ export function computeCodexWorkerStatusMode(input) {
207
+ const workspaceDir = path.resolve(input.workspaceDir);
208
+ if (!directoryExists(workspaceDir)) {
209
+ return { mode: 'degraded', reason: 'workspace_missing', nextAction: 'The workspace directory does not exist; restore it or remove it from the install manifest.' };
210
+ }
211
+ const config = loadPdConfigForPlugin(workspaceDir);
212
+ if (!config.ok) {
213
+ const [first] = config.errors;
214
+ return { mode: 'degraded', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
215
+ }
216
+ const { flags } = computeFeatureFlagsFromConfig(config.effective);
217
+ if (flags['host.codex']?.enabled !== true) {
218
+ return { mode: 'paused', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the Workspace .pd/config.yaml to enable Codex PD behavior.' };
219
+ }
220
+ if (flags.internalization_auto_consumer?.enabled !== true) {
221
+ return { mode: 'paused', reason: 'internalization_auto_consumer_disabled', nextAction: 'Automatic execution is paused; manual commands remain available: pd diagnose, pd runtime internalization run-once.' };
222
+ }
223
+ if (!input.registeredInInstallManifest) {
224
+ return {
225
+ mode: 'manual_action_required',
226
+ reason: 'workspace_not_in_install_manifest',
227
+ nextAction: `No Companion worker is registered for this workspace. Manual path: pd codex ingest catch-up --workspace "${workspaceDir}", then pd diagnose / pd runtime internalization run-once.`,
228
+ };
229
+ }
230
+ return { mode: 'ready' };
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/codex-adapter",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",