create-principles-disciple 1.145.27 → 1.145.29
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/_release/product-identity.json +2 -2
- package/codex-adapter/dist/index.d.ts +4 -0
- package/codex-adapter/dist/index.js +2 -0
- package/codex-adapter/dist/ingestion/admission.js +1 -0
- package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +22 -3
- package/codex-adapter/dist/ingestion/explicit-owner-statement.js +43 -16
- package/codex-adapter/dist/ingestion/turn-context.d.ts +75 -0
- package/codex-adapter/dist/ingestion/turn-context.js +302 -0
- package/codex-adapter/dist/pd-hook.js +87 -36
- package/codex-adapter/dist/worker/workspace-worker.js +4 -1
- package/codex-adapter/package.json +1 -1
- package/core/dist/runtime-v2/context-payload.d.ts +4 -4
- package/core/dist/runtime-v2/context-payload.d.ts.map +1 -1
- package/core/dist/runtime-v2/context-payload.js +1 -0
- package/core/dist/runtime-v2/context-payload.js.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/triage-policy.d.ts +0 -1
- package/core/dist/runtime-v2/evidence-triage/triage-policy.d.ts.map +1 -1
- package/core/dist/runtime-v2/evidence-triage/triage-policy.js +0 -1
- package/core/dist/runtime-v2/evidence-triage/triage-policy.js.map +1 -1
- package/core/dist/runtime-v2/index.d.ts +0 -2
- package/core/dist/runtime-v2/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/index.js +1 -2
- package/core/dist/runtime-v2/index.js.map +1 -1
- package/core/dist/runtime-v2/task-status.d.ts +1 -1
- package/core/package.json +1 -1
- package/host-runtime/dist/governance-signal-admission.d.ts +26 -7
- package/host-runtime/dist/governance-signal-admission.js +104 -34
- package/host-runtime/dist/index.d.ts +0 -1
- package/host-runtime/dist/index.js +0 -3
- package/host-runtime/package.json +1 -1
- package/package.json +2 -2
- package/pd-cli/dist/commands/codex-reconcile.d.ts.map +1 -1
- package/pd-cli/dist/commands/codex-reconcile.js +30 -1
- package/pd-cli/dist/commands/codex-reconcile.js.map +1 -1
- package/pd-cli/dist/commands/pain-explicit.d.ts +8 -0
- package/pd-cli/dist/commands/pain-explicit.d.ts.map +1 -0
- package/pd-cli/dist/commands/pain-explicit.js +89 -0
- package/pd-cli/dist/commands/pain-explicit.js.map +1 -0
- package/pd-cli/dist/index.js +10 -0
- package/pd-cli/dist/index.js.map +1 -1
- package/pd-cli/package.json +1 -1
- package/plugin/dist/bundle.js +334 -334
- package/plugin/dist/hooks/pain-ingress-adapter.d.ts +1 -2
- package/plugin/dist/hooks/triage-adapter.d.ts +3 -1
- package/plugin/dist/openclaw.plugin.json +1 -1
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/package.json +2 -2
- package/workbuddy-adapter/dist/worker/workspace-worker.js +1 -1
- package/workbuddy-adapter/package.json +1 -1
- package/core/dist/runtime-v2/pain-gate/index.d.ts +0 -10
- package/core/dist/runtime-v2/pain-gate/index.d.ts.map +0 -1
- package/core/dist/runtime-v2/pain-gate/index.js +0 -9
- package/core/dist/runtime-v2/pain-gate/index.js.map +0 -1
- package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.d.ts +0 -123
- package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.d.ts.map +0 -1
- package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.js +0 -159
- package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.js.map +0 -1
- package/host-runtime/dist/pain-evidence-ingress.d.ts +0 -16
- package/host-runtime/dist/pain-evidence-ingress.js +0 -15
- package/plugin/dist/core/pain-diagnostic-gate.d.ts +0 -47
- package/plugin/dist/core/thinking-os-parser.d.ts +0 -33
- package/plugin/dist/core/workflow-funnel-loader.d.ts +0 -72
|
@@ -10,6 +10,10 @@ export { processHookInvocation } from './pd-hook.js';
|
|
|
10
10
|
export type { PdHookResult } from './pd-hook.js';
|
|
11
11
|
export * from './codec/index.js';
|
|
12
12
|
export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
13
|
+
export { recordExplicitOwnerStatement } from './ingestion/explicit-owner-statement.js';
|
|
14
|
+
export type { RecordOwnerStatementResult } from './ingestion/explicit-owner-statement.js';
|
|
15
|
+
export { writeCurrentTurnContext, clearCurrentTurnContext, registerCurrentTurnOwnerStatement, TURN_CONTEXT_FILENAME_PREFIX, TURN_CONTEXT_SCHEMA_VERSION } from './ingestion/turn-context.js';
|
|
16
|
+
export type { CodexTurnContext, TurnContextWriteResult, RegisterCurrentTurnResult } from './ingestion/turn-context.js';
|
|
13
17
|
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
14
18
|
export type { TranscriptPathValidation, TranscriptFileIdentity } from './ingestion/transcript-path.js';
|
|
15
19
|
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
@@ -9,6 +9,8 @@ export { CodexHooksHostAdapter } from './host-adapter.js';
|
|
|
9
9
|
export { processHookInvocation } from './pd-hook.js';
|
|
10
10
|
export * from './codec/index.js';
|
|
11
11
|
export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
12
|
+
export { recordExplicitOwnerStatement } from './ingestion/explicit-owner-statement.js';
|
|
13
|
+
export { writeCurrentTurnContext, clearCurrentTurnContext, registerCurrentTurnOwnerStatement, TURN_CONTEXT_FILENAME_PREFIX, TURN_CONTEXT_SCHEMA_VERSION } from './ingestion/turn-context.js';
|
|
12
14
|
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
13
15
|
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
14
16
|
export { ingestCodexConversation, setCodexTranscriptPortForTest, ingestCodexTranscriptFromPath } from './ingestion/ingestion.js';
|
|
@@ -145,6 +145,7 @@ export async function runGovernanceAdmission(args) {
|
|
|
145
145
|
workspaceDir,
|
|
146
146
|
logicalObservationKey: outcome.logicalObservationKey,
|
|
147
147
|
canonicalPainId: outcome.canonicalPainId,
|
|
148
|
+
conversationIngestionEnabled: true,
|
|
148
149
|
});
|
|
149
150
|
if (!cont.ok) {
|
|
150
151
|
push(cont.reason, cont.nextAction);
|
|
@@ -18,15 +18,34 @@ export type RecordOwnerStatementResult = {
|
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
20
|
* Persist one explicit Owner statement. The consent-gated conversation
|
|
21
|
-
* ingestion flag does NOT gate
|
|
22
|
-
* CONTENT are read — only the path is validated for the rollout
|
|
23
|
-
* mirroring SPEC §6.4 rule 3.
|
|
21
|
+
* ingestion flag does NOT gate the REGISTRATION (no conversation tables or
|
|
22
|
+
* transcript CONTENT are read — only the path is validated for the rollout
|
|
23
|
+
* identity), mirroring SPEC §6.4 rule 3. It DOES gate evidence promotion:
|
|
24
|
+
*
|
|
25
|
+
* - `conversationIngestionEnabled: true` — the caller MUST have run the
|
|
26
|
+
* live observation ingestion for this same hook payload BEFORE calling
|
|
27
|
+
* this function: promotion requires the trigger observation
|
|
28
|
+
* (`codex|<rollout>|<turn>|user`) to already be persisted, otherwise the
|
|
29
|
+
* FIRST submission of a rollout degrades with rollout_not_found /
|
|
30
|
+
* trigger_not_found (the observation used to be written only by the
|
|
31
|
+
* ingestion pass that ran after the explicit registration).
|
|
32
|
+
* - `conversationIngestionEnabled: false` — no session observation exists
|
|
33
|
+
* and none may be written (privacy boundary), so promotion is SKIPPED
|
|
34
|
+
* and disclosed as `conversation_ingestion_disabled` instead of
|
|
35
|
+
* attempting it and misreporting a missing substrate.
|
|
24
36
|
*/
|
|
25
37
|
export declare function recordExplicitOwnerStatement(args: {
|
|
38
|
+
/**
|
|
39
|
+
* Audit provenance: omitted = the native hook-read `PD记录痛苦:` prefix
|
|
40
|
+
* entry; 'codex_explicit_agent' = the agent-assisted no-prefix bridge
|
|
41
|
+
* (turn-context.ts). Admission semantics are identical either way.
|
|
42
|
+
*/
|
|
43
|
+
explicitChannel?: 'codex_explicit_agent';
|
|
26
44
|
rawPayload: unknown;
|
|
27
45
|
workspaceDir: string;
|
|
28
46
|
statementText: string;
|
|
29
47
|
env?: {
|
|
30
48
|
CODEX_HOME?: string | undefined;
|
|
31
49
|
};
|
|
50
|
+
conversationIngestionEnabled: boolean;
|
|
32
51
|
}): Promise<RecordOwnerStatementResult>;
|
|
@@ -26,9 +26,21 @@ import { extractFields } from './ingestion-fields.js';
|
|
|
26
26
|
export { OWNER_STATEMENT_PREFIX as CODEX_OWNER_STATEMENT_PREFIX, MAX_OWNER_STATEMENT_CHARS, parseOwnerStatementPrefix } from '@principles/host-runtime';
|
|
27
27
|
/**
|
|
28
28
|
* Persist one explicit Owner statement. The consent-gated conversation
|
|
29
|
-
* ingestion flag does NOT gate
|
|
30
|
-
* CONTENT are read — only the path is validated for the rollout
|
|
31
|
-
* mirroring SPEC §6.4 rule 3.
|
|
29
|
+
* ingestion flag does NOT gate the REGISTRATION (no conversation tables or
|
|
30
|
+
* transcript CONTENT are read — only the path is validated for the rollout
|
|
31
|
+
* identity), mirroring SPEC §6.4 rule 3. It DOES gate evidence promotion:
|
|
32
|
+
*
|
|
33
|
+
* - `conversationIngestionEnabled: true` — the caller MUST have run the
|
|
34
|
+
* live observation ingestion for this same hook payload BEFORE calling
|
|
35
|
+
* this function: promotion requires the trigger observation
|
|
36
|
+
* (`codex|<rollout>|<turn>|user`) to already be persisted, otherwise the
|
|
37
|
+
* FIRST submission of a rollout degrades with rollout_not_found /
|
|
38
|
+
* trigger_not_found (the observation used to be written only by the
|
|
39
|
+
* ingestion pass that ran after the explicit registration).
|
|
40
|
+
* - `conversationIngestionEnabled: false` — no session observation exists
|
|
41
|
+
* and none may be written (privacy boundary), so promotion is SKIPPED
|
|
42
|
+
* and disclosed as `conversation_ingestion_disabled` instead of
|
|
43
|
+
* attempting it and misreporting a missing substrate.
|
|
32
44
|
*/
|
|
33
45
|
export async function recordExplicitOwnerStatement(args) {
|
|
34
46
|
const fields = extractFields(args.rawPayload);
|
|
@@ -56,6 +68,7 @@ export async function recordExplicitOwnerStatement(args) {
|
|
|
56
68
|
text: args.statementText,
|
|
57
69
|
observedAt: new Date().toISOString(),
|
|
58
70
|
explicitOwnerStatement: true,
|
|
71
|
+
...(args.explicitChannel !== undefined ? { explicitChannel: args.explicitChannel } : {}),
|
|
59
72
|
};
|
|
60
73
|
const admitted = admitGovernanceSignals({ workspaceDir: args.workspaceDir, candidates: [candidate] });
|
|
61
74
|
if (!admitted.ok) {
|
|
@@ -71,9 +84,33 @@ export async function recordExplicitOwnerStatement(args) {
|
|
|
71
84
|
if (outcome.disposition === 'rate_limited') {
|
|
72
85
|
return { status: 'rejected', reason: 'rate_limited', nextAction: 'The per-root-session correction quota (5/hour) is exhausted; resubmit later — the statement was NOT recorded.' };
|
|
73
86
|
}
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
87
|
+
// Promotion runs BEFORE the task enqueue (deliberate): the durable task is
|
|
88
|
+
// the receipt's hard guarantee, and promoting first closes the window in
|
|
89
|
+
// which the background worker could start the first diagnosis before the
|
|
90
|
+
// already-available evidence window is promoted. Both steps are
|
|
91
|
+
// independently idempotent and recovered by reconcileGovernanceContinuation
|
|
92
|
+
// (Case A: task missing → created later; Case C: promotion missing →
|
|
93
|
+
// retried once the trigger observation arrives).
|
|
94
|
+
let promotionDegraded;
|
|
95
|
+
if (args.conversationIngestionEnabled) {
|
|
96
|
+
const promoted = promoteAdmittedGovernanceEvidence({
|
|
97
|
+
workspaceDir: args.workspaceDir,
|
|
98
|
+
rolloutIdentity: validated.rolloutIdentity,
|
|
99
|
+
triggerLogicalKey: candidate.logicalObservationKey,
|
|
100
|
+
canonicalPainId: outcome.canonicalPainId,
|
|
101
|
+
});
|
|
102
|
+
if (!promoted.ok) {
|
|
103
|
+
promotionDegraded = `${promoted.reason}`.slice(0, 160);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Ingestion is consent-gated OFF: no session observation was (or may be)
|
|
108
|
+
// written, so promotion is skipped by design, never attempted-and-failed.
|
|
109
|
+
promotionDegraded = 'conversation_ingestion_disabled';
|
|
110
|
+
}
|
|
111
|
+
// Task ensure is the receipt's hard guarantee (SPEC §6.4.5 — a degraded
|
|
112
|
+
// promotion is disclosed, never a failed receipt: the pain + task are
|
|
113
|
+
// durably recorded).
|
|
77
114
|
const ensured = await ensureGovernanceDiagnosticianTask({
|
|
78
115
|
workspaceDir: args.workspaceDir,
|
|
79
116
|
logicalObservationKey: candidate.logicalObservationKey,
|
|
@@ -82,16 +119,6 @@ export async function recordExplicitOwnerStatement(args) {
|
|
|
82
119
|
if (!ensured.ok) {
|
|
83
120
|
return { status: 'rejected', reason: 'continuation_failed', nextAction: `${ensured.reason}: ${ensured.nextAction}`.slice(0, 400) };
|
|
84
121
|
}
|
|
85
|
-
let promotionDegraded;
|
|
86
|
-
const promoted = promoteAdmittedGovernanceEvidence({
|
|
87
|
-
workspaceDir: args.workspaceDir,
|
|
88
|
-
rolloutIdentity: validated.rolloutIdentity,
|
|
89
|
-
triggerLogicalKey: candidate.logicalObservationKey,
|
|
90
|
-
canonicalPainId: outcome.canonicalPainId,
|
|
91
|
-
});
|
|
92
|
-
if (!promoted.ok) {
|
|
93
|
-
promotionDegraded = `${promoted.reason}`.slice(0, 160);
|
|
94
|
-
}
|
|
95
122
|
return {
|
|
96
123
|
status: 'received',
|
|
97
124
|
painId: outcome.canonicalPainId,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type RecordOwnerStatementResult } from './explicit-owner-statement.js';
|
|
2
|
+
export declare const TURN_CONTEXT_FILENAME_PREFIX = "pd_current_turn_context_";
|
|
3
|
+
export declare const TURN_CONTEXT_SCHEMA_VERSION = 1;
|
|
4
|
+
/**
|
|
5
|
+
* Staleness bound for the turn context. Stop/SessionStart withdraw the file,
|
|
6
|
+
* but any early exit on those paths (decode/config failure, host.codex flag
|
|
7
|
+
* flip, crash) leaves residue behind; a context older than this bound is
|
|
8
|
+
* treated as ended and fails closed instead of binding a finished turn. 4h
|
|
9
|
+
* mirrors the workspace-lease TTL default and sits far above the longest
|
|
10
|
+
* legitimate in-turn registration latency.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TURN_CONTEXT_MAX_AGE_MS: number;
|
|
13
|
+
/** Host-written identity of the CURRENT Codex turn. Ids and paths only. */
|
|
14
|
+
export interface CodexTurnContext {
|
|
15
|
+
readonly schemaVersion: number;
|
|
16
|
+
readonly writtenAt: string;
|
|
17
|
+
readonly sessionId: string;
|
|
18
|
+
readonly turnId: string;
|
|
19
|
+
readonly transcriptPath: string;
|
|
20
|
+
readonly codexHome: string;
|
|
21
|
+
readonly workspaceDir: string;
|
|
22
|
+
}
|
|
23
|
+
interface CodexCallerEnvironment {
|
|
24
|
+
CODEX_HOME?: string | undefined;
|
|
25
|
+
CODEX_SESSION_ID?: string | undefined;
|
|
26
|
+
CODEX_THREAD_ID?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
export type TurnContextWriteResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
nextAction: string;
|
|
34
|
+
};
|
|
35
|
+
export type TurnContextClearResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function clearCurrentTurnContext(args: {
|
|
42
|
+
workspaceDir: string;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
turnId?: string;
|
|
45
|
+
}): TurnContextClearResult;
|
|
46
|
+
/**
|
|
47
|
+
* Publish (or withdraw) the current turn context. Called from the
|
|
48
|
+
* UserPromptSubmit branch of the hook. A payload without complete lineage
|
|
49
|
+
* (session/turn/transcript) clears any previous file instead of leaving a
|
|
50
|
+
* stale turn bindable — the invariant is "file exists ⟺ the current turn
|
|
51
|
+
* carries full lineage". Fail-open: a write failure degrades the
|
|
52
|
+
* agent-assisted entry only (the prefix entry never needed this file).
|
|
53
|
+
*/
|
|
54
|
+
export declare function writeCurrentTurnContext(args: {
|
|
55
|
+
rawPayload: unknown;
|
|
56
|
+
workspaceDir: string;
|
|
57
|
+
env?: CodexCallerEnvironment;
|
|
58
|
+
}): TurnContextWriteResult;
|
|
59
|
+
export type RegisterCurrentTurnResult = RecordOwnerStatementResult | {
|
|
60
|
+
status: 'rejected';
|
|
61
|
+
reason: 'turn_context_missing' | 'turn_context_invalid' | 'turn_context_stale' | 'turn_context_workspace_mismatch' | 'turn_context_session_mismatch' | 'caller_session_missing' | 'caller_session_conflict' | 'conversation_ingestion_config_invalid';
|
|
62
|
+
nextAction: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Register one explicit Owner statement through the LIVE turn context — the
|
|
66
|
+
* agent-assisted counterpart of the prefix entry. The statement must be the
|
|
67
|
+
* Owner's verbatim words from the CURRENT conversation turn (1–500 chars,
|
|
68
|
+
* rejected when over the limit — never truncated); lineage comes exclusively
|
|
69
|
+
* from the host-written context file, never from caller-supplied ids.
|
|
70
|
+
*/
|
|
71
|
+
export declare function registerCurrentTurnOwnerStatement(args: {
|
|
72
|
+
workspaceDir: string;
|
|
73
|
+
statementText: string;
|
|
74
|
+
}): Promise<RegisterCurrentTurnResult>;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-turn context bridge for agent-assisted explicit Owner pain
|
|
3
|
+
* registration (no `PD记录痛苦:` prefix).
|
|
4
|
+
*
|
|
5
|
+
* The hook writes the CURRENT turn's host-verified identity — session id,
|
|
6
|
+
* turn id, transcript path, CODEX_HOME, workspace — to a file partitioned by
|
|
7
|
+
* the hook payload's session id. Stop clears only a matching session+turn;
|
|
8
|
+
* SessionStart clears only that session. The agent-driven CLI entry selects
|
|
9
|
+
* the file using the host-bound CODEX_SESSION_ID, so concurrent conversations
|
|
10
|
+
* cannot overwrite or borrow each other's context.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: the file is host-written (hook payload fields only). The
|
|
13
|
+
* bridge re-validates everything it reads as untrusted JSON (rc-1/rc-2),
|
|
14
|
+
* cross-checks the workspace, and `recordExplicitOwnerStatement` re-validates
|
|
15
|
+
* the transcript path (existence + CODEX_HOME containment + rollout identity)
|
|
16
|
+
* at registration time. A cleared or stale context fails closed.
|
|
17
|
+
*
|
|
18
|
+
* Privacy: each file carries identifiers and paths only — never prompt text,
|
|
19
|
+
* never transcript content. Session ids are hashed in filenames. The
|
|
20
|
+
* conversation-ingestion flag does not gate context publication.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
26
|
+
import { loadPdConfigForPlugin } from '@principles/host-runtime';
|
|
27
|
+
import { resolveCodexHome } from './codex-home.js';
|
|
28
|
+
import { extractFields, isRecord } from './ingestion-fields.js';
|
|
29
|
+
import { recordExplicitOwnerStatement, MAX_OWNER_STATEMENT_CHARS } from './explicit-owner-statement.js';
|
|
30
|
+
export const TURN_CONTEXT_FILENAME_PREFIX = 'pd_current_turn_context_';
|
|
31
|
+
export const TURN_CONTEXT_SCHEMA_VERSION = 1;
|
|
32
|
+
/**
|
|
33
|
+
* Staleness bound for the turn context. Stop/SessionStart withdraw the file,
|
|
34
|
+
* but any early exit on those paths (decode/config failure, host.codex flag
|
|
35
|
+
* flip, crash) leaves residue behind; a context older than this bound is
|
|
36
|
+
* treated as ended and fails closed instead of binding a finished turn. 4h
|
|
37
|
+
* mirrors the workspace-lease TTL default and sits far above the longest
|
|
38
|
+
* legitimate in-turn registration latency.
|
|
39
|
+
*/
|
|
40
|
+
export const TURN_CONTEXT_MAX_AGE_MS = 4 * 60 * 60 * 1000;
|
|
41
|
+
function resolveCallerSessionId(env) {
|
|
42
|
+
const sessionId = env.CODEX_SESSION_ID?.trim();
|
|
43
|
+
if (!sessionId)
|
|
44
|
+
return { ok: false, reason: 'caller_session_missing' };
|
|
45
|
+
const threadId = env.CODEX_THREAD_ID?.trim();
|
|
46
|
+
if (threadId && threadId !== sessionId)
|
|
47
|
+
return { ok: false, reason: 'caller_session_conflict' };
|
|
48
|
+
return { ok: true, sessionId };
|
|
49
|
+
}
|
|
50
|
+
function turnContextPath(workspaceDir, sessionId) {
|
|
51
|
+
const sessionKey = createHash('sha256').update(sessionId, 'utf8').digest('hex');
|
|
52
|
+
return path.join(workspaceDir, '.state', `${TURN_CONTEXT_FILENAME_PREFIX}${sessionKey}.json`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Withdraw the current turn context (Stop / SessionStart). Only ENOENT is
|
|
56
|
+
* ignored (nothing to withdraw); any other unlink failure is surfaced so the
|
|
57
|
+
* caller can emit a diagnostic — swallowing it would leave the old context
|
|
58
|
+
* bindable and silently break the fail-closed guarantee (rc-9). A failure
|
|
59
|
+
* here never blocks the hook.
|
|
60
|
+
*/
|
|
61
|
+
function unlinkContext(target) {
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(target);
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error && Object.hasOwn(error, 'code') && Reflect.get(error, 'code') === 'ENOENT')
|
|
68
|
+
return { ok: true };
|
|
69
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
70
|
+
return { ok: false, reason: `clear_failed:${detail.slice(0, 160)}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function restoreWithdrawnContext(staged, target) {
|
|
74
|
+
try {
|
|
75
|
+
// Linking restores only if no newer turn has appeared at the target path.
|
|
76
|
+
fs.linkSync(staged, target);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!(error instanceof Error && Object.hasOwn(error, 'code') && Reflect.get(error, 'code') === 'EEXIST')) {
|
|
80
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return { ok: false, reason: `clear_failed:${detail.slice(0, 160)}` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return unlinkContext(staged);
|
|
85
|
+
}
|
|
86
|
+
export function clearCurrentTurnContext(args) {
|
|
87
|
+
const target = turnContextPath(args.workspaceDir, args.sessionId);
|
|
88
|
+
if (args.turnId === undefined)
|
|
89
|
+
return unlinkContext(target);
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.lstatSync(target).isFile())
|
|
92
|
+
return { ok: false, reason: 'clear_failed:context_not_regular_file' };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof Error && Object.hasOwn(error, 'code') && Reflect.get(error, 'code') === 'ENOENT')
|
|
96
|
+
return { ok: true };
|
|
97
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
98
|
+
return { ok: false, reason: `clear_failed:${detail.slice(0, 160)}` };
|
|
99
|
+
}
|
|
100
|
+
const staged = `${target}.withdraw-${process.pid}-${randomUUID()}`;
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(target, staged);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof Error && Object.hasOwn(error, 'code') && Reflect.get(error, 'code') === 'ENOENT')
|
|
106
|
+
return { ok: true };
|
|
107
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return { ok: false, reason: `clear_failed:${detail.slice(0, 160)}` };
|
|
109
|
+
}
|
|
110
|
+
let current;
|
|
111
|
+
try {
|
|
112
|
+
current = JSON.parse(fs.readFileSync(staged, 'utf8'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return restoreWithdrawnContext(staged, target);
|
|
116
|
+
}
|
|
117
|
+
if (!isRecord(current) || current.sessionId !== args.sessionId || current.turnId !== args.turnId) {
|
|
118
|
+
return restoreWithdrawnContext(staged, target);
|
|
119
|
+
}
|
|
120
|
+
return unlinkContext(staged);
|
|
121
|
+
}
|
|
122
|
+
const CLEAR_FAILURE_NEXT_ACTION = 'The previous turn context could not be withdrawn; the agent-assisted explicit entry stays fail-closed only via its staleness bound. Inspect the workspace .state directory.';
|
|
123
|
+
/**
|
|
124
|
+
* Publish (or withdraw) the current turn context. Called from the
|
|
125
|
+
* UserPromptSubmit branch of the hook. A payload without complete lineage
|
|
126
|
+
* (session/turn/transcript) clears any previous file instead of leaving a
|
|
127
|
+
* stale turn bindable — the invariant is "file exists ⟺ the current turn
|
|
128
|
+
* carries full lineage". Fail-open: a write failure degrades the
|
|
129
|
+
* agent-assisted entry only (the prefix entry never needed this file).
|
|
130
|
+
*/
|
|
131
|
+
export function writeCurrentTurnContext(args) {
|
|
132
|
+
const fields = extractFields(args.rawPayload);
|
|
133
|
+
if (fields === null || fields.sessionId.length === 0) {
|
|
134
|
+
return { ok: false, reason: 'hook_session_missing', nextAction: 'The Codex hook payload has no session identity; no turn context was written or cleared.' };
|
|
135
|
+
}
|
|
136
|
+
if (fields.turnId === null || fields.transcriptPath === null) {
|
|
137
|
+
const cleared = clearCurrentTurnContext({
|
|
138
|
+
workspaceDir: args.workspaceDir,
|
|
139
|
+
sessionId: fields.sessionId,
|
|
140
|
+
...(fields.turnId !== null ? { turnId: fields.turnId } : {}),
|
|
141
|
+
});
|
|
142
|
+
if (!cleared.ok)
|
|
143
|
+
return { ok: false, reason: cleared.reason, nextAction: CLEAR_FAILURE_NEXT_ACTION };
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
const home = resolveCodexHome(args.env);
|
|
147
|
+
if (!home.ok) {
|
|
148
|
+
const cleared = clearCurrentTurnContext({ workspaceDir: args.workspaceDir, sessionId: fields.sessionId, turnId: fields.turnId });
|
|
149
|
+
if (!cleared.ok)
|
|
150
|
+
return { ok: false, reason: cleared.reason, nextAction: CLEAR_FAILURE_NEXT_ACTION };
|
|
151
|
+
return { ok: false, reason: `codex_home:${home.reason}`, nextAction: home.nextAction };
|
|
152
|
+
}
|
|
153
|
+
const context = {
|
|
154
|
+
schemaVersion: TURN_CONTEXT_SCHEMA_VERSION,
|
|
155
|
+
writtenAt: new Date().toISOString(),
|
|
156
|
+
sessionId: fields.sessionId,
|
|
157
|
+
turnId: fields.turnId,
|
|
158
|
+
transcriptPath: fields.transcriptPath,
|
|
159
|
+
codexHome: home.home,
|
|
160
|
+
workspaceDir: args.workspaceDir,
|
|
161
|
+
};
|
|
162
|
+
const target = turnContextPath(args.workspaceDir, fields.sessionId);
|
|
163
|
+
const temp = `${target}.tmp-${process.pid}`;
|
|
164
|
+
try {
|
|
165
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
166
|
+
fs.writeFileSync(temp, JSON.stringify(context));
|
|
167
|
+
fs.renameSync(temp, target);
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(temp);
|
|
173
|
+
}
|
|
174
|
+
catch { /* best-effort temp cleanup */ }
|
|
175
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
176
|
+
const cleared = clearCurrentTurnContext({ workspaceDir: args.workspaceDir, sessionId: fields.sessionId });
|
|
177
|
+
const reason = `write_failed:${detail.slice(0, 160)}${cleared.ok ? '' : `;${cleared.reason}`}`;
|
|
178
|
+
const nextAction = cleared.ok
|
|
179
|
+
? 'The previous session context was withdrawn after this write failed. Inspect workspace .state writability before retrying the agent-assisted explicit entry.'
|
|
180
|
+
: 'The write failed and the previous session context could not be withdrawn. Do not retry the agent-assisted entry until the workspace .state context is manually cleared.';
|
|
181
|
+
return { ok: false, reason, nextAction };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function requireStringField(record, key) {
|
|
185
|
+
const value = Object.hasOwn(record, key) ? record[key] : undefined;
|
|
186
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
187
|
+
}
|
|
188
|
+
function readTurnContext(workspaceDir, sessionId) {
|
|
189
|
+
let raw;
|
|
190
|
+
try {
|
|
191
|
+
raw = fs.readFileSync(turnContextPath(workspaceDir, sessionId), 'utf8');
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { error: 'turn_context_missing' };
|
|
195
|
+
}
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(raw);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return { error: 'turn_context_invalid' };
|
|
202
|
+
}
|
|
203
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== TURN_CONTEXT_SCHEMA_VERSION)
|
|
204
|
+
return { error: 'turn_context_invalid' };
|
|
205
|
+
const writtenAt = requireStringField(parsed, 'writtenAt');
|
|
206
|
+
const contextSessionId = requireStringField(parsed, 'sessionId');
|
|
207
|
+
const turnId = requireStringField(parsed, 'turnId');
|
|
208
|
+
const transcriptPath = requireStringField(parsed, 'transcriptPath');
|
|
209
|
+
const codexHome = requireStringField(parsed, 'codexHome');
|
|
210
|
+
const contextWorkspaceDir = requireStringField(parsed, 'workspaceDir');
|
|
211
|
+
if (writtenAt === null || contextSessionId === null || turnId === null || transcriptPath === null || codexHome === null || contextWorkspaceDir === null) {
|
|
212
|
+
return { error: 'turn_context_invalid' };
|
|
213
|
+
}
|
|
214
|
+
const writtenAtMs = Date.parse(writtenAt);
|
|
215
|
+
if (Number.isNaN(writtenAtMs))
|
|
216
|
+
return { error: 'turn_context_invalid' };
|
|
217
|
+
if (Date.now() - writtenAtMs > TURN_CONTEXT_MAX_AGE_MS)
|
|
218
|
+
return { error: 'turn_context_stale' };
|
|
219
|
+
return {
|
|
220
|
+
schemaVersion: TURN_CONTEXT_SCHEMA_VERSION,
|
|
221
|
+
writtenAt,
|
|
222
|
+
sessionId: contextSessionId,
|
|
223
|
+
turnId,
|
|
224
|
+
transcriptPath,
|
|
225
|
+
codexHome,
|
|
226
|
+
workspaceDir: contextWorkspaceDir,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Register one explicit Owner statement through the LIVE turn context — the
|
|
231
|
+
* agent-assisted counterpart of the prefix entry. The statement must be the
|
|
232
|
+
* Owner's verbatim words from the CURRENT conversation turn (1–500 chars,
|
|
233
|
+
* rejected when over the limit — never truncated); lineage comes exclusively
|
|
234
|
+
* from the host-written context file, never from caller-supplied ids.
|
|
235
|
+
*/
|
|
236
|
+
export async function registerCurrentTurnOwnerStatement(args) {
|
|
237
|
+
const caller = resolveCallerSessionId(process.env);
|
|
238
|
+
if (!caller.ok) {
|
|
239
|
+
return {
|
|
240
|
+
status: 'rejected',
|
|
241
|
+
reason: caller.reason,
|
|
242
|
+
nextAction: caller.reason === 'caller_session_missing'
|
|
243
|
+
? 'This command has no host-bound Codex session identity; nothing was read or recorded. Run it from the originating Codex session.'
|
|
244
|
+
: 'The host session and thread identities conflict; nothing was read or recorded. Retry from the originating Codex session.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const context = readTurnContext(args.workspaceDir, caller.sessionId);
|
|
248
|
+
if ('error' in context) {
|
|
249
|
+
const nextAction = context.error === 'turn_context_missing'
|
|
250
|
+
? 'No live Codex turn context: the turn already completed, the session restarted, or the PD hook did not run. The native prefix entry (PD记录痛苦:<原文>) remains available in a NEW Owner message.'
|
|
251
|
+
: context.error === 'turn_context_stale'
|
|
252
|
+
? `The live turn context is stale (older than ${TURN_CONTEXT_MAX_AGE_MS / 3_600_000} hours); treat the turn as ended. Nothing was recorded — use the native prefix entry (PD记录痛苦:<原文>) in a NEW Owner message.`
|
|
253
|
+
: 'The Codex turn context file is malformed or an unsupported schema version; nothing was recorded. Retry in a new turn or use the native prefix entry (PD记录痛苦:<原文>).';
|
|
254
|
+
return {
|
|
255
|
+
status: 'rejected',
|
|
256
|
+
reason: context.error,
|
|
257
|
+
nextAction,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (path.resolve(context.workspaceDir) !== path.resolve(args.workspaceDir)) {
|
|
261
|
+
return {
|
|
262
|
+
status: 'rejected',
|
|
263
|
+
reason: 'turn_context_workspace_mismatch',
|
|
264
|
+
nextAction: 'The live turn context belongs to a different PD workspace; resolve the workspace the same way pd-status does and retry. Nothing was recorded.',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (context.sessionId !== caller.sessionId) {
|
|
268
|
+
return {
|
|
269
|
+
status: 'rejected',
|
|
270
|
+
reason: 'turn_context_session_mismatch',
|
|
271
|
+
nextAction: 'The turn context does not belong to this host-bound Codex session; nothing was recorded. Retry from the session that supplied the Owner statement.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const statement = args.statementText.trim();
|
|
275
|
+
if (statement.length === 0) {
|
|
276
|
+
return { status: 'rejected', reason: 'statement_empty', nextAction: 'The Owner statement is empty; nothing was recorded. Pass the Owner verbatim words (1–500 characters).' };
|
|
277
|
+
}
|
|
278
|
+
if (statement.length > MAX_OWNER_STATEMENT_CHARS) {
|
|
279
|
+
return { status: 'rejected', reason: 'statement_too_long', nextAction: `The Owner statement exceeds ${MAX_OWNER_STATEMENT_CHARS} characters; nothing was recorded. Ask the Owner to condense THEIR words — do not summarize on their behalf.` };
|
|
280
|
+
}
|
|
281
|
+
const config = loadPdConfigForPlugin(args.workspaceDir);
|
|
282
|
+
if (!config.ok) {
|
|
283
|
+
return {
|
|
284
|
+
status: 'rejected',
|
|
285
|
+
reason: 'conversation_ingestion_config_invalid',
|
|
286
|
+
nextAction: 'The workspace .pd/config.yaml could not be validated, so conversation-ingestion consent could not be determined; nothing was recorded. Repair the workspace configuration and retry.',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const conversationIngestionEnabled = computeFeatureFlagsFromConfig(config.effective).flags.codex_conversation_ingestion?.enabled === true;
|
|
290
|
+
return recordExplicitOwnerStatement({
|
|
291
|
+
rawPayload: {
|
|
292
|
+
session_id: context.sessionId,
|
|
293
|
+
turn_id: context.turnId,
|
|
294
|
+
transcript_path: context.transcriptPath,
|
|
295
|
+
},
|
|
296
|
+
workspaceDir: args.workspaceDir,
|
|
297
|
+
statementText: statement,
|
|
298
|
+
env: { CODEX_HOME: context.codexHome },
|
|
299
|
+
explicitChannel: 'codex_explicit_agent',
|
|
300
|
+
conversationIngestionEnabled,
|
|
301
|
+
});
|
|
302
|
+
}
|