@principles/host-runtime 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
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
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isHostEvent, isHostEventResult, } from '@principles/core/host';
3
- import { getPromptModelCatalog, resolvePromptBudget } from '@principles/core/runtime-v2';
3
+ import { getPromptModelCatalog, operatorDeclaredContextWindow, resolvePromptBudget } from '@principles/core/runtime-v2';
4
+ import { loadPdConfigForPlugin } from './pd-config.js';
4
5
  import { buildActivePrinciplePromptContext } from './active-principle-prompt.js';
5
6
  import { createProductionRuleHostGate } from './production-rulehost-gate.js';
6
7
  import { createProductionPainEvidenceHandler } from './production-pain-evidence.js';
@@ -212,6 +213,19 @@ export function createProductionHostRuntime(options = {}) {
212
213
  // reports `legacy_fifo_prefix_v1` honestly. The selector's fair-rotation
213
214
  // capability is retained and stays reachable for callers that DO hold a
214
215
  // legitimate advancing round authority.
216
+ // PRI-1020: the Owner's declared window (.pd/config.yaml promptBudget)
217
+ // is tier 0 of the budget chain — resolved per host and fed into the
218
+ // SAME single resolver below. One extra sync config read per prompt
219
+ // build (the candidates reader loads it independently): deliberate, so
220
+ // a config flip applies on the next turn with no cache to invalidate.
221
+ // Malformed config degrades to defaults (no declaration) and is already
222
+ // surfaced as config_invalid warnings by readPromptActivationCandidates.
223
+ const promptBudgetHostKind = options.hostKind === 'openclaw' || options.hostKind === 'codex' || options.hostKind === 'workbuddy'
224
+ ? options.hostKind
225
+ : undefined;
226
+ const declaredWindow = promptBudgetHostKind === undefined
227
+ ? undefined
228
+ : operatorDeclaredContextWindow(loadPdConfigForPlugin(event.context.workspaceDir).effective.config.promptBudget, promptBudgetHostKind);
215
229
  const prompt = await buildActivePrinciplePromptContext({
216
230
  workspaceDir: event.context.workspaceDir,
217
231
  excludePrincipleIds: options.promptExcludePrincipleIds?.(event),
@@ -221,6 +235,8 @@ export function createProductionHostRuntime(options = {}) {
221
235
  // Codex+WorkBuddy model id) against the installed local Pi AI
222
236
  // catalog — no network, no LLM call. Missing/untrusted facts
223
237
  // resolve to the legacy fixed budget with an observable reason.
238
+ // PRI-1020: an Owner declaration outranks all of these (tier 0).
239
+ ...(declaredWindow !== undefined ? { operatorDeclared: { contextWindowTokens: declaredWindow } } : {}),
224
240
  hostCapacity: event.context.modelContext === undefined
225
241
  ? undefined
226
242
  : {
@@ -307,3 +323,6 @@ export * from './behavior-history.js';
307
323
  export * from './codex-rule-context-provider.js';
308
324
  export { createWorkspaceBehaviorEvidencePort } from './workspace-behavior-evidence.js';
309
325
  export * from './explicit-owner-statement-prefix.js';
326
+ export { listObservedDesignGateSessions } from './workspace-behavior-evidence.js';
327
+ export { readDesignGateSpecSnapshot, describeCodexRuleContextFact } from './design-gate-facts.js';
328
+ 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;
@@ -1,4 +1,4 @@
1
- import { PROMPT_BUDGET_POLICY_VERSION, type PromptBudgetCapacitySource, type PromptBudgetCapacityStatus, type PromptBudgetFallbackReason, type PromptBudgetResolution } from '@principles/core/runtime-v2';
1
+ import { PROMPT_BUDGET_POLICY_VERSION, type PromptBudgetCapacitySource, type PromptBudgetCapacityStatus, type PromptBudgetFallbackReason, type PromptBudgetResolution, type PromptBudgetConfig } from '@principles/core/runtime-v2';
2
2
  import { type PromptInjectionTargetHost } from './prompt-injection-projection.js';
3
3
  /**
4
4
  * PRI-1017: runtime budget OBSERVATION read model over the EXISTING
@@ -174,3 +174,12 @@ export declare function selectConsoleRuntimeBudgetFacts(input: {
174
174
  * rides events or console payloads.
175
175
  */
176
176
  export declare function runtimeBudgetObservationToResolution(observation: RuntimePromptBudgetObservation): PromptBudgetResolution;
177
+ /**
178
+ * PRI-1020: the Owner's declared window as a budget resolution, for console
179
+ * surfaces with NO fresh runtime observation. Goes through the same single
180
+ * resolver (tier 0) so an injected forecast and the console fallback can
181
+ * never disagree about what a declaration means. Undefined when the
182
+ * workspace declares nothing — the caller then keeps the historical fixed
183
+ * fallback.
184
+ */
185
+ export declare function operatorDeclaredBudgetResolution(promptBudget: PromptBudgetConfig | undefined, hostKind: PromptInjectionTargetHost): PromptBudgetResolution | undefined;
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { PROMPT_BUDGET_POLICY_VERSION, RUNTIME_V2_PRINCIPLE_BUDGET, isValidContextWindowTokens, promptBudgetCharsForContextWindow, } from '@principles/core/runtime-v2';
3
+ import { PROMPT_BUDGET_POLICY_VERSION, RUNTIME_V2_PRINCIPLE_BUDGET, isValidContextWindowTokens, promptBudgetCharsForContextWindow, resolvePromptBudget, operatorDeclaredContextWindow, } from '@principles/core/runtime-v2';
4
4
  import { isPromptInjectionTargetHost, } from './prompt-injection-projection.js';
5
5
  /**
6
6
  * PRI-1017: runtime budget OBSERVATION read model over the EXISTING
@@ -104,6 +104,7 @@ function parseObservedAt(value) {
104
104
  return Number.isFinite(parsed) ? parsed : null;
105
105
  }
106
106
  const BUDGET_CAPACITY_SOURCES = new Set([
107
+ 'operator_declared',
107
108
  'host_effective_window',
108
109
  'pi_catalog_exact',
109
110
  'legacy_fallback',
@@ -177,7 +178,11 @@ function narrowObservation(parsed, workspaceDir, nowMs) {
177
178
  // formula derives from it; the legacy fallback needs the legacy budget, no
178
179
  // window, and a bounded reason. Anything else is not a trustworthy fact.
179
180
  if (rawStatus === 'confirmed') {
180
- if (rawSource !== 'host_effective_window' && rawSource !== 'pi_catalog_exact')
181
+ // PRI-1020: operator_declared joins the confirmed set — a declared window
182
+ // is a real window (the Owner's explicit cap), so its observations carry
183
+ // the window and the exact formula budget like the other confirmed
184
+ // sources.
185
+ if (rawSource !== 'operator_declared' && rawSource !== 'host_effective_window' && rawSource !== 'pi_catalog_exact')
181
186
  return null;
182
187
  if (!isValidContextWindowTokens(rawWindow))
183
188
  return null;
@@ -447,3 +452,17 @@ export function runtimeBudgetObservationToResolution(observation) {
447
452
  ...(observation.fallbackReason !== null ? { fallbackReason: observation.fallbackReason } : {}),
448
453
  };
449
454
  }
455
+ /**
456
+ * PRI-1020: the Owner's declared window as a budget resolution, for console
457
+ * surfaces with NO fresh runtime observation. Goes through the same single
458
+ * resolver (tier 0) so an injected forecast and the console fallback can
459
+ * never disagree about what a declaration means. Undefined when the
460
+ * workspace declares nothing — the caller then keeps the historical fixed
461
+ * fallback.
462
+ */
463
+ export function operatorDeclaredBudgetResolution(promptBudget, hostKind) {
464
+ const declaredWindow = operatorDeclaredContextWindow(promptBudget, hostKind);
465
+ if (declaredWindow === undefined)
466
+ return undefined;
467
+ return resolvePromptBudget({ operatorDeclared: { contextWindowTokens: declaredWindow } });
468
+ }
@@ -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.1",
3
+ "version": "0.13.0",
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.2",
34
+ "@principles/core": "^1.296.0",
35
35
  "@principles/install-layout": "^0.4.0",
36
36
  "better-sqlite3": "^13.0.3",
37
37
  "js-yaml": "^5.4.3"