@principles/host-runtime 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import type { ArtificerTrustedFactsPort } from '@principles/core/runtime-v2';
2
+ /**
3
+ * Codex generation can author rules against the same DesignGateFact schema
4
+ * that the native PreToolUse provider supplies. This declares capability only;
5
+ * it never resolves the current generated PI task as approved.
6
+ */
7
+ export declare function createCodexArtificerTrustedFactsPort(): ArtificerTrustedFactsPort;
@@ -0,0 +1,17 @@
1
+ import { describeCodexRuleContextFact } from './design-gate-facts.js';
2
+ /**
3
+ * Codex generation can author rules against the same DesignGateFact schema
4
+ * that the native PreToolUse provider supplies. This declares capability only;
5
+ * it never resolves the current generated PI task as approved.
6
+ */
7
+ export function createCodexArtificerTrustedFactsPort() {
8
+ return {
9
+ resolveRequiredFact: ({ factKind }) => ({
10
+ resolution: 'unknown',
11
+ detail: factKind === 'design_approval'
12
+ ? 'Generation verifies host fact capability; current task approval is resolved only at native PreToolUse.'
13
+ : `Codex has no trusted source for required fact '${factKind}'.`,
14
+ }),
15
+ describeRequiredFact: describeCodexRuleContextFact,
16
+ };
17
+ }
@@ -2,19 +2,57 @@ import path from 'node:path';
2
2
  import { computeBehaviorFacts, } from '@principles/core/runtime-v2';
3
3
  import { assembleBehaviorHistory } from './behavior-history.js';
4
4
  import { readWorkspaceRuleContextRowsBefore } from './workspace-behavior-evidence.js';
5
+ import { readLatestCodexDesignGateDeclaration, resolveCodexDesignGateFact } from './design-gate-facts.js';
5
6
  const HISTORY_LIMIT = 100;
6
7
  const MAX_REASON_LENGTH = 240;
8
+ const MAX_PATCH_BYTES = 1024 * 1024;
9
+ const APPROVED_READONLY_COMMANDS = new Set([
10
+ 'git status',
11
+ 'git status --short',
12
+ 'git diff --stat',
13
+ 'git log -1 --oneline',
14
+ ]);
7
15
  function isRecord(value) {
8
16
  return typeof value === 'object' && value !== null && !Array.isArray(value);
9
17
  }
10
- function unavailable(reason) {
18
+ function patchTargets(params, projectDir) {
19
+ if (params === null)
20
+ return undefined;
21
+ const patch = typeof params.patch === 'string'
22
+ ? params.patch
23
+ : typeof params.input === 'string' ? params.input : null;
24
+ if (patch === null || Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES)
25
+ return undefined;
26
+ const targets = [];
27
+ for (const line of patch.split(/\r?\n/)) {
28
+ const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move (?:from|to): (.+)$/.exec(line);
29
+ if (!match)
30
+ continue;
31
+ const value = (match[1] ?? match[2] ?? '').trim();
32
+ if (value.length === 0 || value.includes('\0'))
33
+ return undefined;
34
+ targets.push(path.resolve(projectDir, value));
35
+ }
36
+ return targets.length > 0 ? targets : undefined;
37
+ }
38
+ function safeReadonlyCommand(params) {
39
+ if (params === null)
40
+ return false;
41
+ const command = typeof params.command === 'string' ? params.command : null;
42
+ return command !== null && APPROVED_READONLY_COMMANDS.has(command.trim());
43
+ }
44
+ function unavailable(reason, designGate) {
11
45
  const history = {
12
46
  status: 'unavailable',
13
47
  unavailableReason: reason.replace(/\s+/g, ' ').slice(0, MAX_REASON_LENGTH),
14
48
  truncated: false,
15
49
  calls: [],
16
50
  };
17
- return { version: 2, history, facts: computeBehaviorFacts(history, null, null) };
51
+ return {
52
+ version: 2,
53
+ history,
54
+ facts: { ...computeBehaviorFacts(history, null, null), ...(designGate !== undefined ? { designGate } : {}) },
55
+ };
18
56
  }
19
57
  /**
20
58
  * Read a bounded same-session trajectory window for Codex PreToolUse events.
@@ -27,24 +65,55 @@ export function createCodexRuleContextProvider(options) {
27
65
  const workspaceDir = path.resolve(options.workspaceDir);
28
66
  const projectDir = path.resolve(options.projectDir);
29
67
  return (request) => {
68
+ const canonicalKind = options.toolSemantics.resolve(request.toolName);
69
+ const targetKind = canonicalKind === 'agent' ? 'other' : canonicalKind;
70
+ const raw = request.rawPayload;
71
+ const rawParams = isRecord(raw) && isRecord(raw.toolInput) && isRecord(raw.toolInput.params)
72
+ ? raw.toolInput.params
73
+ : null;
74
+ const readonlyExecute = targetKind === 'execute' && safeReadonlyCommand(rawParams);
75
+ // Fail fast before the fact resolution below: a non-string or empty
76
+ // sessionId cannot bind a declaration (an empty id resolves the fact to
77
+ // unknown) and would otherwise throw inside resolveCodexDesignGateFact,
78
+ // surfacing as a provider_failed fault instead of the precise reason.
79
+ // The enabled and workspace-scope checks deliberately stay AFTER
80
+ // resolution: the design-gate fact is independent of the
81
+ // rulecode_context_v2 capability (it reads the governance declaration
82
+ // store directly), so those degraded paths must still carry the resolved
83
+ // fact for enforcement (SPEC-RULECODE-CODEX-LOCAL-CLOSURE-001 §6).
84
+ if (typeof request.sessionId !== 'string' || request.sessionId.trim().length === 0) {
85
+ return unavailable('session_id_invalid');
86
+ }
87
+ const designGate = resolveCodexDesignGateFact({
88
+ governanceWorkspacePath: workspaceDir,
89
+ projectWorkspacePath: projectDir,
90
+ hostSessionId: request.sessionId,
91
+ targetPath: targetKind === 'execute' ? projectDir : request.targetPath,
92
+ ...(targetKind === 'write' && request.toolName === 'apply_patch'
93
+ ? { targetPaths: patchTargets(rawParams, projectDir) ?? [] }
94
+ : {}),
95
+ ...(targetKind === 'execute' ? { projectScopedExecution: true } : {}),
96
+ toolKind: readonlyExecute ? 'read' : targetKind,
97
+ }, {
98
+ getLatestDeclaration(projectWorkspacePath, hostSessionId) {
99
+ return readLatestCodexDesignGateDeclaration(workspaceDir, projectWorkspacePath, hostSessionId);
100
+ },
101
+ });
30
102
  if (!options.enabled)
31
- return unavailable('codex_rule_context_disabled');
103
+ return unavailable('codex_rule_context_disabled', designGate);
32
104
  if (path.resolve(request.workspaceDir) !== workspaceDir)
33
- return unavailable('workspace_scope_mismatch');
34
- if (typeof request.sessionId !== 'string' || request.sessionId.trim().length === 0)
35
- return unavailable('session_id_invalid');
36
- const raw = request.rawPayload;
105
+ return unavailable('workspace_scope_mismatch', designGate);
37
106
  const toolInput = isRecord(raw) && isRecord(raw.toolInput) ? raw.toolInput : null;
38
107
  if (toolInput === null
39
108
  || toolInput.toolName !== request.toolName
40
109
  || !isRecord(toolInput.params)
41
110
  || !path.isAbsolute(projectDir)
42
111
  || options.currentToolUseId.trim().length === 0) {
43
- return unavailable('codex_pre_tool_scope_mismatch');
112
+ return unavailable('codex_pre_tool_scope_mismatch', designGate);
44
113
  }
45
114
  const cutoff = options.cutoffAt;
46
115
  if (!(cutoff instanceof Date) || !Number.isFinite(cutoff.getTime()))
47
- return unavailable('history_cutoff_invalid');
116
+ return unavailable('history_cutoff_invalid', designGate);
48
117
  try {
49
118
  const historyRows = readWorkspaceRuleContextRowsBefore(workspaceDir, {
50
119
  sessionId: request.sessionId,
@@ -54,9 +123,9 @@ export function createCodexRuleContextProvider(options) {
54
123
  limit: HISTORY_LIMIT,
55
124
  });
56
125
  if (historyRows.unscopedPriorRows)
57
- return unavailable('trajectory_project_scope_incomplete');
126
+ return unavailable('trajectory_project_scope_incomplete', designGate);
58
127
  if (historyRows.rows.length === 0)
59
- return unavailable('no_prior_session_history');
128
+ return unavailable('no_prior_session_history', designGate);
60
129
  const history = assembleBehaviorHistory(historyRows.rows, historyRows.truncated, {
61
130
  projectDir,
62
131
  toolSemantics: options.toolSemantics,
@@ -79,11 +148,11 @@ export function createCodexRuleContextProvider(options) {
79
148
  return {
80
149
  version: 2,
81
150
  history,
82
- facts,
151
+ facts: { ...facts, designGate },
83
152
  };
84
153
  }
85
154
  catch (error) {
86
- return unavailable(`trajectory_history_unavailable:${error instanceof Error ? error.message : String(error)}`);
155
+ return unavailable(`trajectory_history_unavailable:${error instanceof Error ? error.message : String(error)}`, designGate);
87
156
  }
88
157
  };
89
158
  }
@@ -0,0 +1,37 @@
1
+ import { type DesignGateFact } from '@principles/core/runtime-v2';
2
+ export declare const DESIGN_GATE_FACT_KIND: 'design_approval';
3
+ export declare const DESIGN_GATE_FACT_INPUT_PATH: 'input.context.facts.designGate';
4
+ export declare const CODEX_DESIGN_GATE_SOURCE: 'codex-native-pretool-design-gate-v1';
5
+ export interface DesignGateSpecSnapshot {
6
+ realPath: string;
7
+ digest: string;
8
+ byteLength: number;
9
+ }
10
+ export interface CodexDesignGateRequest {
11
+ governanceWorkspacePath: string;
12
+ projectWorkspacePath: string;
13
+ hostSessionId: string;
14
+ targetPath: string;
15
+ /** All paths named by a bounded native patch payload, when available. */
16
+ targetPaths?: readonly string[];
17
+ /** True only when the Owner explicitly scoped execute invocations to the project root. */
18
+ projectScopedExecution?: boolean;
19
+ toolKind: 'read' | 'search' | 'write' | 'execute' | 'other';
20
+ }
21
+ export interface DesignGateDeclarationReader {
22
+ getLatestDeclaration(projectWorkspacePath: string, hostSessionId: string): unknown;
23
+ }
24
+ /** Read and hash the current SPEC after realpath confinement on every call. */
25
+ export declare function readDesignGateSpecSnapshot(projectWorkspacePath: string, specPath: string): DesignGateSpecSnapshot;
26
+ /** Resolve a live Codex event against the latest Owner declaration, without caching or writes. */
27
+ export declare function resolveCodexDesignGateFact(request: CodexDesignGateRequest, declarationReader: DesignGateDeclarationReader): DesignGateFact;
28
+ /** Read the current declaration from the governance workspace's existing state.db. */
29
+ export declare function readLatestCodexDesignGateDeclaration(governanceWorkspacePath: string, projectWorkspacePath: string, hostSessionId: string): unknown;
30
+ export declare function describeCodexRuleContextFact(input: {
31
+ factKind: string;
32
+ }): {
33
+ supported: boolean;
34
+ schemaVersion?: string;
35
+ inputPath?: string;
36
+ source?: string;
37
+ };
@@ -0,0 +1,249 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { DESIGN_GATE_FACT_SCHEMA_VERSION, SqliteConnection, SqliteDesignGateStore, isDesignGateDeclaration, } from '@principles/core/runtime-v2';
5
+ export const DESIGN_GATE_FACT_KIND = 'design_approval';
6
+ export const DESIGN_GATE_FACT_INPUT_PATH = 'input.context.facts.designGate';
7
+ export const CODEX_DESIGN_GATE_SOURCE = 'codex-native-pretool-design-gate-v1';
8
+ const MAX_SPEC_BYTES = 2 * 1024 * 1024;
9
+ function inside(parent, child) {
10
+ const relative = path.relative(parent, child);
11
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
12
+ }
13
+ function realPathForTarget(candidate) {
14
+ if (!path.isAbsolute(candidate))
15
+ throw new Error('target_path_not_absolute');
16
+ let current = path.resolve(candidate);
17
+ const suffix = [];
18
+ while (true) {
19
+ try {
20
+ const realCurrent = fs.realpathSync.native(current);
21
+ return path.join(realCurrent, ...suffix.reverse());
22
+ }
23
+ catch {
24
+ const parent = path.dirname(current);
25
+ if (parent === current)
26
+ throw new Error('target_path_unresolvable');
27
+ suffix.push(path.basename(current));
28
+ current = parent;
29
+ }
30
+ }
31
+ }
32
+ /** Read and hash the current SPEC after realpath confinement on every call. */
33
+ export function readDesignGateSpecSnapshot(projectWorkspacePath, specPath) {
34
+ if (!path.isAbsolute(projectWorkspacePath) || !path.isAbsolute(specPath))
35
+ throw new Error('spec_path_invalid');
36
+ const projectRoot = fs.realpathSync.native(projectWorkspacePath);
37
+ const realPath = fs.realpathSync.native(specPath);
38
+ if (!inside(projectRoot, realPath))
39
+ throw new Error('spec_path_invalid');
40
+ // Open once and validate the descriptor: the checked and the read bytes are
41
+ // guaranteed to be the same file (no path-recheck race between stat and read).
42
+ const fd = fs.openSync(realPath, 'r');
43
+ try {
44
+ const stat = fs.fstatSync(fd);
45
+ if (!stat.isFile() || stat.size > MAX_SPEC_BYTES)
46
+ throw new Error('spec_path_invalid');
47
+ const bytes = fs.readFileSync(fd);
48
+ if (bytes.byteLength > MAX_SPEC_BYTES)
49
+ throw new Error('spec_path_invalid');
50
+ return {
51
+ realPath,
52
+ digest: createHash('sha256').update(bytes).digest('hex'),
53
+ byteLength: bytes.byteLength,
54
+ };
55
+ }
56
+ finally {
57
+ fs.closeSync(fd);
58
+ }
59
+ }
60
+ function scopeReference(paths, actions) {
61
+ const canonical = JSON.stringify({
62
+ paths: [...paths].sort((left, right) => left.localeCompare(right)),
63
+ actions: [...actions].sort((left, right) => left.localeCompare(right)),
64
+ });
65
+ return createHash('sha256').update(canonical).digest('hex');
66
+ }
67
+ function unknownFact(taskRef, reason, requiresApproval = true) {
68
+ return {
69
+ version: 1,
70
+ sourceRef: null,
71
+ revision: null,
72
+ requiresApproval,
73
+ taskRef,
74
+ specDigest: null,
75
+ scopeRef: null,
76
+ decisionTaskRef: null,
77
+ decisionSpecDigest: null,
78
+ decisionScopeRef: null,
79
+ resolution: 'unknown',
80
+ revoked: false,
81
+ invalidationReason: reason,
82
+ };
83
+ }
84
+ function targetIsInDeclaredScope(projectPath, targetPath, scopePaths) {
85
+ try {
86
+ const target = realPathForTarget(targetPath);
87
+ const projectRoot = fs.realpathSync.native(projectPath);
88
+ if (!inside(projectRoot, target))
89
+ return null;
90
+ for (const declaredPath of scopePaths) {
91
+ const resolved = path.isAbsolute(declaredPath) ? declaredPath : path.resolve(projectRoot, declaredPath);
92
+ const scopePath = realPathForTarget(resolved);
93
+ if (!inside(projectRoot, scopePath))
94
+ continue;
95
+ let directory = false;
96
+ try {
97
+ directory = fs.statSync(scopePath).isDirectory();
98
+ }
99
+ catch { /* a new file may be an exact scope */ }
100
+ if (target === scopePath || (directory && inside(scopePath, target)))
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function everyTargetIsInDeclaredScope(projectPath, targetPaths, scopePaths) {
110
+ if (targetPaths.length === 0)
111
+ return null;
112
+ for (const targetPath of targetPaths) {
113
+ if (!path.isAbsolute(targetPath))
114
+ return null;
115
+ const matches = targetIsInDeclaredScope(projectPath, targetPath, scopePaths);
116
+ if (matches !== true)
117
+ return matches;
118
+ }
119
+ return true;
120
+ }
121
+ /** Resolve a live Codex event against the latest Owner declaration, without caching or writes. */
122
+ export function resolveCodexDesignGateFact(request, declarationReader) {
123
+ const taskRef = request.hostSessionId.trim().length > 0 ? `codex:${request.hostSessionId}` : null;
124
+ if (taskRef === null)
125
+ return unknownFact(null, 'host_identity_unavailable');
126
+ if (!path.isAbsolute(request.governanceWorkspacePath) || !path.isAbsolute(request.projectWorkspacePath)) {
127
+ return unknownFact(taskRef, 'project_scope_mismatch');
128
+ }
129
+ // Codex's declared read/search tool semantics are sufficient evidence that
130
+ // the current action is investigative and does not require design approval.
131
+ if (request.toolKind === 'read' || request.toolKind === 'search') {
132
+ return {
133
+ version: 1,
134
+ sourceRef: null,
135
+ revision: null,
136
+ requiresApproval: false,
137
+ taskRef,
138
+ specDigest: null,
139
+ scopeRef: null,
140
+ decisionTaskRef: null,
141
+ decisionSpecDigest: null,
142
+ decisionScopeRef: null,
143
+ resolution: 'not_required',
144
+ revoked: false,
145
+ };
146
+ }
147
+ if (request.toolKind !== 'write' && request.toolKind !== 'execute') {
148
+ return unknownFact(taskRef, 'action_scope_unknown');
149
+ }
150
+ // An execute declaration scoped to `.` governs invocations from this native
151
+ // Codex project/session. It does not claim to confine the process effects.
152
+ let projectRoot;
153
+ let governanceRoot;
154
+ try {
155
+ projectRoot = fs.realpathSync.native(request.projectWorkspacePath);
156
+ governanceRoot = fs.realpathSync.native(request.governanceWorkspacePath);
157
+ }
158
+ catch {
159
+ return unknownFact(taskRef, 'project_scope_mismatch');
160
+ }
161
+ let declarationValue;
162
+ try {
163
+ declarationValue = declarationReader.getLatestDeclaration(projectRoot, request.hostSessionId);
164
+ }
165
+ catch {
166
+ return unknownFact(taskRef, 'declaration_unavailable');
167
+ }
168
+ if (declarationValue === null)
169
+ return unknownFact(taskRef, 'unregistered_scope');
170
+ if (!isDesignGateDeclaration(declarationValue))
171
+ return unknownFact(taskRef, 'declaration_invalid');
172
+ const declaration = declarationValue;
173
+ try {
174
+ if (fs.realpathSync.native(declaration.projectWorkspacePath) !== projectRoot
175
+ || fs.realpathSync.native(declaration.governanceWorkspacePath) !== governanceRoot
176
+ || declaration.hostSessionId !== request.hostSessionId
177
+ || declaration.taskRef !== taskRef) {
178
+ return unknownFact(taskRef, 'project_scope_mismatch');
179
+ }
180
+ }
181
+ catch {
182
+ return unknownFact(taskRef, 'project_scope_mismatch');
183
+ }
184
+ const action = request.toolKind;
185
+ if (!declaration.scope.actions.includes(action))
186
+ return unknownFact(taskRef, 'action_scope_unknown');
187
+ const projectScopedExecution = request.toolKind === 'execute'
188
+ && request.projectScopedExecution === true
189
+ && declaration.scope.paths.includes('.');
190
+ const targetScope = projectScopedExecution
191
+ ? true
192
+ : request.targetPaths !== undefined
193
+ ? everyTargetIsInDeclaredScope(projectRoot, request.targetPaths, declaration.scope.paths)
194
+ : request.toolKind === 'execute'
195
+ ? null
196
+ : targetIsInDeclaredScope(projectRoot, request.targetPath, declaration.scope.paths);
197
+ if (targetScope !== true)
198
+ return unknownFact(taskRef, 'target_scope_unknown');
199
+ let snapshot;
200
+ try {
201
+ snapshot = readDesignGateSpecSnapshot(projectRoot, declaration.specPath);
202
+ }
203
+ catch {
204
+ return unknownFact(taskRef, 'spec_path_invalid');
205
+ }
206
+ if (snapshot.digest !== declaration.specDigest)
207
+ return unknownFact(taskRef, 'spec_digest_mismatch');
208
+ const currentScopeRef = scopeReference(declaration.scope.paths, declaration.scope.actions);
209
+ const resolution = declaration.revoked ? 'not_approved' : declaration.decision;
210
+ return {
211
+ version: 1,
212
+ sourceRef: declaration.sourceRef,
213
+ revision: declaration.revision,
214
+ requiresApproval: declaration.revoked || declaration.decision !== 'not_required',
215
+ taskRef,
216
+ specDigest: snapshot.digest,
217
+ scopeRef: currentScopeRef,
218
+ decisionTaskRef: declaration.taskRef,
219
+ decisionSpecDigest: declaration.specDigest,
220
+ decisionScopeRef: currentScopeRef,
221
+ resolution,
222
+ revoked: declaration.revoked,
223
+ ...(declaration.revoked ? { invalidationReason: 'declaration_revoked' } : {}),
224
+ };
225
+ }
226
+ /** Read the current declaration from the governance workspace's existing state.db. */
227
+ export function readLatestCodexDesignGateDeclaration(governanceWorkspacePath, projectWorkspacePath, hostSessionId) {
228
+ const connection = new SqliteConnection({
229
+ workspaceDir: governanceWorkspacePath,
230
+ readonly: true,
231
+ bootstrapIfMissing: false,
232
+ });
233
+ try {
234
+ return new SqliteDesignGateStore(connection).getLatestDeclaration(projectWorkspacePath, hostSessionId);
235
+ }
236
+ finally {
237
+ connection.close();
238
+ }
239
+ }
240
+ export function describeCodexRuleContextFact(input) {
241
+ if (input.factKind !== DESIGN_GATE_FACT_KIND)
242
+ return { supported: false };
243
+ return {
244
+ supported: DESIGN_GATE_FACT_SCHEMA_VERSION === '1',
245
+ schemaVersion: DESIGN_GATE_FACT_SCHEMA_VERSION,
246
+ inputPath: DESIGN_GATE_FACT_INPUT_PATH,
247
+ source: CODEX_DESIGN_GATE_SOURCE,
248
+ };
249
+ }
@@ -146,8 +146,22 @@ export type AdmitGovernanceSignalsResult = {
146
146
  } | Degradation;
147
147
  /** Public generic intake stays Codex + explicit WorkBuddy only. */
148
148
  export declare function admitGovernanceSignals(input: AdmitGovernanceSignalsInput): AdmitGovernanceSignalsResult;
149
- /** Pure check: no DB, no filesystem, no classifier, no historical transcript. */
150
- export declare function assessWorkbuddyLiveKeyword(text: string, sessionId: string): {
149
+ /**
150
+ * AUDIT-P3-01 (2026-10-21): WorkBuddy live-keyword 检测接入共享 live 词库
151
+ * (`<workspace>/.state/correction_keywords.json`,seed ∪ learned,mtime 刷新,
152
+ * 文件缺失/损坏降级 seed-only)。返回的是完整共享 store——"只允许 high 精度
153
+ * correction 词触发自动 Pain"由 assess 的 matched 判据(isSignal && STRONG &&
154
+ * correction && matchedPrecision==='high')与 admitOne 的 isStrongHighPrecision
155
+ * 门共同强制,与 OpenClaw 检测共享同一套 precision 语义(earned-high 需
156
+ * TP≥3 且零 FP;owner_promoted/seed 需权重≥0.7)。
157
+ */
158
+ export declare function resolveSharedCorrectionKeywordStoreForDetection(workspaceDir: string): UnifiedKeywordStore;
159
+ /**
160
+ * Pure check: no DB, no classifier, no historical transcript. The optional
161
+ * store parameter is how the shared live vocabulary enters (AUDIT-P3-01);
162
+ * omitted, the check keeps the P0 four-term projection (backward compatible).
163
+ */
164
+ export declare function assessWorkbuddyLiveKeyword(text: string, sessionId: string, store?: UnifiedKeywordStore): {
151
165
  matched: boolean;
152
166
  reason: 'matched' | 'not_a_signal' | 'prompt_too_large';
153
167
  };
@@ -726,17 +726,33 @@ function workbuddyP0KeywordStore() {
726
726
  }
727
727
  return { version: seed.version, terms };
728
728
  }
729
- /** Pure check: no DB, no filesystem, no classifier, no historical transcript. */
730
- export function assessWorkbuddyLiveKeyword(text, sessionId) {
729
+ /**
730
+ * AUDIT-P3-01 (2026-10-21): WorkBuddy live-keyword 检测接入共享 live 词库
731
+ * (`<workspace>/.state/correction_keywords.json`,seed ∪ learned,mtime 刷新,
732
+ * 文件缺失/损坏降级 seed-only)。返回的是完整共享 store——"只允许 high 精度
733
+ * correction 词触发自动 Pain"由 assess 的 matched 判据(isSignal && STRONG &&
734
+ * correction && matchedPrecision==='high')与 admitOne 的 isStrongHighPrecision
735
+ * 门共同强制,与 OpenClaw 检测共享同一套 precision 语义(earned-high 需
736
+ * TP≥3 且零 FP;owner_promoted/seed 需权重≥0.7)。
737
+ */
738
+ export function resolveSharedCorrectionKeywordStoreForDetection(workspaceDir) {
739
+ return createSharedCorrectionKeywordStore({ workspaceDir }).resolve();
740
+ }
741
+ /**
742
+ * Pure check: no DB, no classifier, no historical transcript. The optional
743
+ * store parameter is how the shared live vocabulary enters (AUDIT-P3-01);
744
+ * omitted, the check keeps the P0 four-term projection (backward compatible).
745
+ */
746
+ export function assessWorkbuddyLiveKeyword(text, sessionId, store) {
731
747
  if (Buffer.byteLength(text, 'utf8') > WORKBUDDY_MAX_PROMPT_BYTES) {
732
748
  return { matched: false, reason: 'prompt_too_large' };
733
749
  }
734
- const store = workbuddyP0KeywordStore();
750
+ const resolved = store ?? workbuddyP0KeywordStore();
735
751
  // Prefer precision to recall: reject quoted/embedded/reported phrases.
736
- if (!Object.keys(store.terms).some(term => text.trimStart().toLowerCase().startsWith(term.toLowerCase()))) {
752
+ if (!Object.keys(resolved.terms).some(term => text.trimStart().toLowerCase().startsWith(term.toLowerCase()))) {
737
753
  return { matched: false, reason: 'not_a_signal' };
738
754
  }
739
- const out = collectSync(text, sessionId, store, SYNC_DETECTION_CONFIG, new Date().toISOString());
755
+ const out = collectSync(text, sessionId, resolved, SYNC_DETECTION_CONFIG, new Date().toISOString());
740
756
  const matched = out.isSignal && out.strength === 'STRONG' &&
741
757
  out.type === 'correction' && out.matchedPrecision === 'high';
742
758
  return { matched, reason: matched ? 'matched' : 'not_a_signal' };
@@ -769,7 +785,11 @@ export function admitTrustedWorkbuddyLiveCorrection(input) {
769
785
  if (prompt.trimStart().startsWith('PD记录痛苦')) {
770
786
  return { ok: false, reason: 'explicit_owner_statement_reserved', nextAction: 'Use original Owner intake; no keyword fallback.' };
771
787
  }
772
- if (!assessWorkbuddyLiveKeyword(prompt, sessionId).matched) {
788
+ // AUDIT-P3-01: 预检与准入共用同一份共享 live 词库(单一真理源)——预检命中
789
+ // 词与 admitOne 的检测判定不会漂移。自动 Pain 仍只允许 high 精度 correction
790
+ // 词(两道门各自强制),无 LLM、无 transcript 读取。
791
+ const sharedStore = resolveSharedCorrectionKeywordStoreForDetection(input.workspaceDir);
792
+ if (!assessWorkbuddyLiveKeyword(prompt, sessionId, sharedStore).matched) {
773
793
  return { ok: true, outcomes: [] };
774
794
  }
775
795
  const nativeIdentity = JSON.stringify(['workbuddy', sessionId, generationId]);
@@ -785,7 +805,7 @@ export function admitTrustedWorkbuddyLiveCorrection(input) {
785
805
  text: prompt,
786
806
  observedAt: (input.now ?? new Date()).toISOString(),
787
807
  }],
788
- keywordStore: workbuddyP0KeywordStore(),
808
+ keywordStore: sharedStore,
789
809
  ...(input.now ? { now: input.now } : {}),
790
810
  ...(input.databaseFactory ? { databaseFactory: input.databaseFactory } : {}),
791
811
  }, true);
package/dist/index.d.ts CHANGED
@@ -91,3 +91,6 @@ export * from './behavior-history.js';
91
91
  export * from './codex-rule-context-provider.js';
92
92
  export { createWorkspaceBehaviorEvidencePort } from './workspace-behavior-evidence.js';
93
93
  export * from './explicit-owner-statement-prefix.js';
94
+ export { listObservedDesignGateSessions } from './workspace-behavior-evidence.js';
95
+ export { readDesignGateSpecSnapshot, describeCodexRuleContextFact } from './design-gate-facts.js';
96
+ export { createCodexArtificerTrustedFactsPort } from './codex-artificer-trusted-facts.js';
package/dist/index.js CHANGED
@@ -307,3 +307,6 @@ export * from './behavior-history.js';
307
307
  export * from './codex-rule-context-provider.js';
308
308
  export { createWorkspaceBehaviorEvidencePort } from './workspace-behavior-evidence.js';
309
309
  export * from './explicit-owner-statement-prefix.js';
310
+ export { listObservedDesignGateSessions } from './workspace-behavior-evidence.js';
311
+ export { readDesignGateSpecSnapshot, describeCodexRuleContextFact } from './design-gate-facts.js';
312
+ export { createCodexArtificerTrustedFactsPort } from './codex-artificer-trusted-facts.js';
@@ -43,6 +43,13 @@ async function withinGateDeadline(value, startedAt) {
43
43
  function isRecord(value) {
44
44
  return typeof value === 'object' && value !== null && !Array.isArray(value);
45
45
  }
46
+ function requiresDesignGateFact(value) {
47
+ if (!isRecord(value) || !Object.hasOwn(value, 'requiredFactKinds'))
48
+ return false;
49
+ return Array.isArray(value.requiredFactKinds)
50
+ && value.requiredFactKinds.every((kind) => typeof kind === 'string')
51
+ && value.requiredFactKinds.includes('design_approval');
52
+ }
46
53
  function readToolInput(rawPayload) {
47
54
  if (!isRecord(rawPayload) || !Object.hasOwn(rawPayload, 'toolInput'))
48
55
  return null;
@@ -375,7 +382,24 @@ export function createProductionRuleHostGate(options = {}) {
375
382
  // sibling rules (v1 or well-fed v2) keep evaluating, and no
376
383
  // governance state changes. Intentional behavior change vs the
377
384
  // pre-fix "legal unavailable still executed" path.
378
- if (context === undefined || context.history.status !== 'available') {
385
+ //
386
+ // PRI-1019 exception: a rule whose ONLY declared dependence is
387
+ // the design_approval fact stays eligible on an actionable fact
388
+ // (resolution !== 'unknown') even when history is unavailable.
389
+ // SPEC-RULECODE-CODEX-LOCAL-CLOSURE-001 §6 requires a managed
390
+ // action with an authoritative not_approved to be blocked
391
+ // regardless of history, and the Artificer prompt contract
392
+ // (V2_CONTEXT_INSTRUCTION) requires history-dependent behavior
393
+ // to allow while the independently supplied fact stays
394
+ // enforceable. Without this exception the first managed action
395
+ // of a new session (no prior history) would never evaluate the
396
+ // rule, and an authoritative not_approved could not block.
397
+ const designGateFact = context?.facts.designGate;
398
+ const designGateActionable = designGateFact !== undefined && designGateFact.resolution !== 'unknown'
399
+ ? designGateFact
400
+ : undefined;
401
+ if ((context === undefined || context.history.status !== 'available')
402
+ && !(requiresDesignGateFact(content.ruleHostInputContract) && designGateActionable !== undefined)) {
379
403
  // Reference identity against the frozen sentinel distinguishes
380
404
  // "the provider faulted" from "the provider legitimately
381
405
  // declared history unavailable" (same wire shape, different
@@ -388,6 +412,18 @@ export function createProductionRuleHostGate(options = {}) {
388
412
  addWarning(warnings, `rule_context_v2_unavailable: ${fault} (activation=${activationId})`, 'enable and wire the host rule context provider, or regenerate the rule without context dependence');
389
413
  continue;
390
414
  }
415
+ const inputContract = content.ruleHostInputContract;
416
+ if (isRecord(inputContract) && Object.hasOwn(inputContract, 'requiredFactKinds')) {
417
+ if (!Array.isArray(inputContract.requiredFactKinds)
418
+ || !inputContract.requiredFactKinds.every((kind) => typeof kind === 'string')) {
419
+ addWarning(warnings, `rule_required_fact_contract_invalid: activation=${activationId}`, 'regenerate the rule artifact from validated Scribe execution conditions');
420
+ continue;
421
+ }
422
+ if (requiresDesignGateFact(inputContract) && designGateActionable === undefined) {
423
+ addWarning(warnings, `rule_required_fact_unavailable: design_approval (activation=${activationId})`, 'verify the current Owner design declaration and scope before retrying');
424
+ continue;
425
+ }
426
+ }
391
427
  }
392
428
  const ruleId = typeof content.ruleId === 'string' ? content.ruleId : typeof row.source_rule_id === 'string' ? row.source_rule_id : artifactId;
393
429
  const principleId = typeof content.principleId === 'string' ? content.principleId : typeof row.source_principle_id === 'string' ? row.source_principle_id : ruleId;
@@ -19,3 +19,12 @@ export declare function readWorkspaceRuleContextRowsBefore(workspaceDir: string,
19
19
  truncated: boolean;
20
20
  unscopedPriorRows: boolean;
21
21
  };
22
+ /**
23
+ * List only Codex sessions proven by normalized host evidence and a matching
24
+ * project-scoped trajectory row. The trajectory schema has no host_kind on
25
+ * tool_calls, so that table alone is never used to infer the host.
26
+ */
27
+ export declare function listObservedDesignGateSessions(workspaceDir: string): {
28
+ projectWorkspacePath: string;
29
+ hostSessionId: string;
30
+ }[];
@@ -1,6 +1,8 @@
1
1
  /** Read-only adapter over the canonical trajectory store; never creates/migrates it. */
2
2
  import path from 'node:path';
3
+ import fs from 'node:fs';
3
4
  import Database from 'better-sqlite3';
5
+ import { SqliteConnection } from '@principles/core/runtime-v2';
4
6
  import { createOwnerEvidenceScopeError } from '@principles/core/runtime-v2/internalization/evidence-readiness';
5
7
  import { BehaviorExamplePackAssembler } from './behavior-example-pack-assembler.js';
6
8
  import { resolveWorkspaceHostToolSemantics } from './host-tool-semantic-resolver.js';
@@ -119,3 +121,86 @@ export function readWorkspaceRuleContextRowsBefore(workspaceDir, input) {
119
121
  db.close();
120
122
  }
121
123
  }
124
+ /**
125
+ * List only Codex sessions proven by normalized host evidence and a matching
126
+ * project-scoped trajectory row. The trajectory schema has no host_kind on
127
+ * tool_calls, so that table alone is never used to infer the host.
128
+ */
129
+ export function listObservedDesignGateSessions(workspaceDir) {
130
+ const connection = new SqliteConnection({ workspaceDir, readonly: true, bootstrapIfMissing: false });
131
+ let evidenceRefs;
132
+ try {
133
+ const db = connection.getDb();
134
+ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'intervention_evidence_records'").get();
135
+ if (exists === undefined)
136
+ return [];
137
+ const rawRows = db.prepare(`
138
+ SELECT native_refs_json, recorded_at
139
+ FROM intervention_evidence_records
140
+ ORDER BY recorded_at DESC
141
+ LIMIT 1000
142
+ `).all();
143
+ const found = new Map();
144
+ for (const raw of rawRows) {
145
+ if (!record(raw) || typeof raw.native_refs_json !== 'string' || typeof raw.recorded_at !== 'string')
146
+ continue;
147
+ let refs;
148
+ try {
149
+ refs = JSON.parse(raw.native_refs_json);
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ if (!record(refs) || refs.hostKind !== 'codex' || typeof refs.sessionId !== 'string' || refs.sessionId.trim().length === 0)
155
+ continue;
156
+ if (!found.has(refs.sessionId))
157
+ found.set(refs.sessionId, raw.recorded_at);
158
+ }
159
+ evidenceRefs = [...found].map(([hostSessionId, lastObservedAt]) => ({ hostSessionId, lastObservedAt }));
160
+ }
161
+ finally {
162
+ connection.close();
163
+ }
164
+ const trajectoryPath = path.join(workspaceDir, '.state', 'trajectory.db');
165
+ const trajectory = new Database(trajectoryPath, { readonly: true, fileMustExist: true });
166
+ try {
167
+ const selectProject = trajectory.prepare(`
168
+ SELECT project_dir, MAX(created_at) AS last_seen
169
+ FROM tool_calls
170
+ WHERE session_id = ? AND project_dir IS NOT NULL
171
+ GROUP BY project_dir
172
+ ORDER BY last_seen DESC
173
+ `);
174
+ const candidates = [];
175
+ for (const evidence of evidenceRefs) {
176
+ const rows = selectProject.all(evidence.hostSessionId);
177
+ for (const raw of rows) {
178
+ if (!record(raw) || typeof raw.project_dir !== 'string' || !path.isAbsolute(raw.project_dir)
179
+ || typeof raw.last_seen !== 'string')
180
+ continue;
181
+ try {
182
+ candidates.push({
183
+ projectWorkspacePath: fs.realpathSync.native(raw.project_dir),
184
+ hostSessionId: evidence.hostSessionId,
185
+ seenAt: raw.last_seen > evidence.lastObservedAt ? raw.last_seen : evidence.lastObservedAt,
186
+ });
187
+ }
188
+ catch { /* stale project paths are not offered as selectable sessions */ }
189
+ }
190
+ }
191
+ const unique = new Map();
192
+ for (const candidate of candidates) {
193
+ const key = `${candidate.projectWorkspacePath}\0${candidate.hostSessionId}`;
194
+ const current = unique.get(key);
195
+ if (!current || candidate.seenAt > current.seenAt)
196
+ unique.set(key, candidate);
197
+ }
198
+ return [...unique.values()]
199
+ .sort((left, right) => right.seenAt.localeCompare(left.seenAt))
200
+ .slice(0, 100)
201
+ .map(({ projectWorkspacePath, hostSessionId }) => ({ projectWorkspacePath, hostSessionId }));
202
+ }
203
+ finally {
204
+ trajectory.close();
205
+ }
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "lint": "eslint \"src/**/*.ts\""
32
32
  },
33
33
  "dependencies": {
34
- "@principles/core": "^1.295.0",
34
+ "@principles/core": "^1.295.3",
35
35
  "@principles/install-layout": "^0.4.0",
36
36
  "better-sqlite3": "^13.0.3",
37
37
  "js-yaml": "^5.4.3"