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.
Files changed (63) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/codex-adapter/dist/index.d.ts +4 -0
  3. package/codex-adapter/dist/index.js +2 -0
  4. package/codex-adapter/dist/ingestion/admission.js +1 -0
  5. package/codex-adapter/dist/ingestion/explicit-owner-statement.d.ts +22 -3
  6. package/codex-adapter/dist/ingestion/explicit-owner-statement.js +43 -16
  7. package/codex-adapter/dist/ingestion/turn-context.d.ts +75 -0
  8. package/codex-adapter/dist/ingestion/turn-context.js +302 -0
  9. package/codex-adapter/dist/pd-hook.js +87 -36
  10. package/codex-adapter/dist/worker/workspace-worker.js +4 -1
  11. package/codex-adapter/package.json +1 -1
  12. package/core/dist/runtime-v2/context-payload.d.ts +4 -4
  13. package/core/dist/runtime-v2/context-payload.d.ts.map +1 -1
  14. package/core/dist/runtime-v2/context-payload.js +1 -0
  15. package/core/dist/runtime-v2/context-payload.js.map +1 -1
  16. package/core/dist/runtime-v2/evidence-triage/triage-policy.d.ts +0 -1
  17. package/core/dist/runtime-v2/evidence-triage/triage-policy.d.ts.map +1 -1
  18. package/core/dist/runtime-v2/evidence-triage/triage-policy.js +0 -1
  19. package/core/dist/runtime-v2/evidence-triage/triage-policy.js.map +1 -1
  20. package/core/dist/runtime-v2/index.d.ts +0 -2
  21. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  22. package/core/dist/runtime-v2/index.js +1 -2
  23. package/core/dist/runtime-v2/index.js.map +1 -1
  24. package/core/dist/runtime-v2/task-status.d.ts +1 -1
  25. package/core/package.json +1 -1
  26. package/host-runtime/dist/governance-signal-admission.d.ts +26 -7
  27. package/host-runtime/dist/governance-signal-admission.js +104 -34
  28. package/host-runtime/dist/index.d.ts +0 -1
  29. package/host-runtime/dist/index.js +0 -3
  30. package/host-runtime/package.json +1 -1
  31. package/package.json +2 -2
  32. package/pd-cli/dist/commands/codex-reconcile.d.ts.map +1 -1
  33. package/pd-cli/dist/commands/codex-reconcile.js +30 -1
  34. package/pd-cli/dist/commands/codex-reconcile.js.map +1 -1
  35. package/pd-cli/dist/commands/pain-explicit.d.ts +8 -0
  36. package/pd-cli/dist/commands/pain-explicit.d.ts.map +1 -0
  37. package/pd-cli/dist/commands/pain-explicit.js +89 -0
  38. package/pd-cli/dist/commands/pain-explicit.js.map +1 -0
  39. package/pd-cli/dist/index.js +10 -0
  40. package/pd-cli/dist/index.js.map +1 -1
  41. package/pd-cli/package.json +1 -1
  42. package/plugin/dist/bundle.js +334 -334
  43. package/plugin/dist/hooks/pain-ingress-adapter.d.ts +1 -2
  44. package/plugin/dist/hooks/triage-adapter.d.ts +3 -1
  45. package/plugin/dist/openclaw.plugin.json +1 -1
  46. package/plugin/openclaw.plugin.json +1 -1
  47. package/plugin/package.json +1 -1
  48. package/release-manager/package.json +2 -2
  49. package/workbuddy-adapter/dist/worker/workspace-worker.js +1 -1
  50. package/workbuddy-adapter/package.json +1 -1
  51. package/core/dist/runtime-v2/pain-gate/index.d.ts +0 -10
  52. package/core/dist/runtime-v2/pain-gate/index.d.ts.map +0 -1
  53. package/core/dist/runtime-v2/pain-gate/index.js +0 -9
  54. package/core/dist/runtime-v2/pain-gate/index.js.map +0 -1
  55. package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.d.ts +0 -123
  56. package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.d.ts.map +0 -1
  57. package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.js +0 -159
  58. package/core/dist/runtime-v2/pain-gate/pain-diagnostic-gate-policy.js.map +0 -1
  59. package/host-runtime/dist/pain-evidence-ingress.d.ts +0 -16
  60. package/host-runtime/dist/pain-evidence-ingress.js +0 -15
  61. package/plugin/dist/core/pain-diagnostic-gate.d.ts +0 -47
  62. package/plugin/dist/core/thinking-os-parser.d.ts +0 -33
  63. package/plugin/dist/core/workflow-funnel-loader.d.ts +0 -72
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "productVersion": "2.3.13",
4
- "sourceCommit": "0abb21264600cd66a57c7960cd089cf854f0768b"
3
+ "productVersion": "2.3.14",
4
+ "sourceCommit": "9b17043a502f9342d4f59d4f381de380907b8cf9"
5
5
  }
@@ -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 this path (no conversation tables or transcript
22
- * CONTENT are read — only the path is validated for the rollout identity),
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 this path (no conversation tables or transcript
30
- * CONTENT are read — only the path is validated for the rollout identity),
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
- // Task ensure is the receipt's hard guarantee; evidence promotion is
75
- // best-effort (SPEC §6.4.5 — a degraded promotion is disclosed, never a
76
- // failed receipt: the pain + task are durably recorded).
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
+ }