@principles/host-runtime 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './prompt-injection-projection.js';
9
9
  export * from './prompt-activation-deliverability.js';
10
10
  export * from './prompt-injection-status.js';
11
11
  export * from './prompt-injection-event-evidence.js';
12
+ export * from './prompt-injection-budget-observation.js';
12
13
  export * from './pd-config.js';
13
14
  export * from './production-rulehost-gate.js';
14
15
  export * from './rule-implementation-runtime.js';
package/dist/index.js CHANGED
@@ -15,6 +15,10 @@ export * from './prompt-injection-status.js';
15
15
  // SPEC PD_PROMPT_CAPACITY_V1 Phase C (AC-12): honest read-model over the
16
16
  // existing injection-event JSONL — unknown vs proven-zero never conflated.
17
17
  export * from './prompt-injection-event-evidence.js';
18
+ // PRI-1017: runtime budget OBSERVATION read model over the SAME injection
19
+ // events — console surfaces forecast under the budget the last real
20
+ // injection used (SPEC §6.1), labelled with source + observation time.
21
+ export * from './prompt-injection-budget-observation.js';
18
22
  export * from './pd-config.js';
19
23
  export * from './production-rulehost-gate.js';
20
24
  export * from './rule-implementation-runtime.js';
@@ -0,0 +1,176 @@
1
+ import { PROMPT_BUDGET_POLICY_VERSION, type PromptBudgetCapacitySource, type PromptBudgetCapacityStatus, type PromptBudgetFallbackReason, type PromptBudgetResolution } from '@principles/core/runtime-v2';
2
+ import { type PromptInjectionTargetHost } from './prompt-injection-projection.js';
3
+ /**
4
+ * PRI-1017: runtime budget OBSERVATION read model over the EXISTING
5
+ * injection-event JSONL (`<workspaceDir>/{.pd,.state}/logs/events_<date>.jsonl`,
6
+ * written by the OpenClaw EventLog and the Codex/WorkBuddy shared path via
7
+ * appendEventLogLine).
8
+ *
9
+ * Why this exists: SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 forbids the
10
+ * actual injection from running a dynamic budget while the Console forecasts
11
+ * under a different fixed 2000-char budget. The console holds no live host
12
+ * facts, but the RUNTIME persists the resolution it actually used —
13
+ * `runtime_v2_prompt_activations_injected` carries budgetChars, capacity
14
+ * provenance and hostKind. This module reads those facts back so console
15
+ * surfaces forecast under the same budget the last real injection used.
16
+ *
17
+ * Honesty rules this model enforces (SPEC §6.1/§7):
18
+ * - An observation is a FACT ABOUT THE PAST, never a live claim: callers must
19
+ * surface `observedAt`; the console labels it "most recent runtime
20
+ * observation", not a current confirmation.
21
+ * - No logs / no budget facts ⇒ absent, never a fabricated window; the caller
22
+ * keeps the honest fixed-fallback policy.
23
+ * - A latest observation older than RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS is
24
+ * `stale` — the caller must NOT present it as the current budget.
25
+ * - Observations bind to ONE host: OpenClaw's window is never used for a
26
+ * Codex or WorkBuddy forecast.
27
+ * - Untrusted input discipline: every line is parsed as `unknown` and
28
+ * narrowed with typeof guards + closed-set checks; structurally invalid
29
+ * lines are counted in `parseFailures` and skipped loudly, never guessed.
30
+ * - Events predating the model-aware policy carry no provenance fields; they
31
+ * are counted as `legacyEventsSkipped` and produce NO observation — a
32
+ * missing provenance field is "policy unknown", never a capacity claim.
33
+ * - Bounded I/O: only the newest bytes of the most recent daily files are read
34
+ * (tail window), the file cap applying PER candidate log directory, so a
35
+ * runaway or crafted log cannot make a Console request read an unbounded
36
+ * amount of data. Only bounded budget metadata is retained and returned;
37
+ * other event payloads are not cached.
38
+ */
39
+ /**
40
+ * Candidate directories for the runtime-v2 event log, in priority order.
41
+ * Shared with the RuleCode telemetry collector (ERR-031: both readers derive
42
+ * from one list — the v1 EventLog writer emits `.state/logs`, no production
43
+ * code ever created `.pd/logs`).
44
+ */
45
+ export declare const RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS: readonly string[];
46
+ /**
47
+ * Bounded I/O: only the most recent daily files are scanned — PER candidate
48
+ * log directory (`RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS`), not globally. The
49
+ * cap is deliberately per directory: both directories describe the same
50
+ * workspace's log convention and either can hold the newest event for a given
51
+ * host (the reader takes latest-wins ACROSS them), so a global cap would let
52
+ * one directory's files starve the other and drop a live host observation.
53
+ * Worst case per read is therefore `RUNTIME_BUDGET_OBSERVATION_MAX_FILES ×
54
+ * RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS.length` files (14 today) and the same
55
+ * count × `RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES` bytes (56 MiB today).
56
+ */
57
+ export declare const RUNTIME_BUDGET_OBSERVATION_MAX_FILES = 7;
58
+ /**
59
+ * Bounded I/O: per file, only the newest bytes are read. The writer appends,
60
+ * so the latest observation per host lives at the TAIL; a file larger than
61
+ * this window is tail-read and its older prefix skipped (counted in
62
+ * `truncatedFiles`) instead of materialising an unbounded log in the console
63
+ * process. 4 MiB is ~3.4x the largest real day observed on 2026-10-21
64
+ * (WorkBuddy 1.17 MiB, Codex 0.87 MiB, OpenClaw 0.98 MiB across 7 files).
65
+ */
66
+ export declare const RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES: number;
67
+ /**
68
+ * Maximum age of an observation that may be presented as a CURRENT runtime
69
+ * fact. The runtime refreshes the observation on every prompt build, so an
70
+ * active workspace holds minute-old facts; beyond a day the model may have
71
+ * been switched and the window is no longer evidence about the present.
72
+ */
73
+ export declare const RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS: number;
74
+ /**
75
+ * Clock-skew allowance for event timestamps. A writer clock ahead of ours is
76
+ * normal; an observation further in the future is not a fact about the
77
+ * present and must never be selected as the current budget.
78
+ */
79
+ export declare const RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS: number;
80
+ /** Console-side bounded reason: a runtime observation exists but is too old. */
81
+ export declare const RUNTIME_BUDGET_OBSERVATION_STALE_REASON: 'runtime_budget_observation_stale';
82
+ export interface RuntimePromptBudgetObservation {
83
+ /** The workspace the observed event was bound to (event `data.workspaceDir`). */
84
+ workspaceDir: string;
85
+ /** Host that produced the observation; observations never cross hosts. */
86
+ hostKind: PromptInjectionTargetHost;
87
+ /** Budget the real injection ran under (UTF-16 code units). */
88
+ budgetChars: number;
89
+ capacitySource: PromptBudgetCapacitySource;
90
+ capacityStatus: PromptBudgetCapacityStatus;
91
+ /** The context window the budget derived from; null when unknown. */
92
+ contextWindowTokens: number | null;
93
+ /** Bounded fallback reason carried by the event; null when confirmed. */
94
+ fallbackReason: PromptBudgetFallbackReason | null;
95
+ /** Policy version the observed budget was computed under (closed literal). */
96
+ policyVersion: typeof PROMPT_BUDGET_POLICY_VERSION | null;
97
+ /** ISO timestamp of the observed event (`ts` envelope field). */
98
+ observedAt: string;
99
+ }
100
+ export interface RuntimeBudgetObservationReport {
101
+ /** Latest observation per host (most recent by event `ts`). */
102
+ observations: RuntimePromptBudgetObservation[];
103
+ filesRead: string[];
104
+ lines: number;
105
+ parseFailures: number;
106
+ /** Injection events without model-aware budget provenance (pre-policy events). */
107
+ legacyEventsSkipped: number;
108
+ /** Injection events whose budget policy version differs from the current one. */
109
+ foreignPolicyEventsSkipped: number;
110
+ /**
111
+ * Files larger than the bounded read window whose older prefix was skipped.
112
+ * Non-zero means `lines`/skip counters describe the read window only.
113
+ */
114
+ truncatedFiles: number;
115
+ note: string;
116
+ }
117
+ export type RuntimeBudgetObservationSelection = {
118
+ kind: 'usable';
119
+ observation: RuntimePromptBudgetObservation;
120
+ } | {
121
+ kind: 'stale';
122
+ observation: RuntimePromptBudgetObservation;
123
+ } | {
124
+ kind: 'absent';
125
+ };
126
+ /**
127
+ * Read the most recent budget observation per host from the workspace's
128
+ * runtime-v2 event logs. Never throws: an unreadable log directory degrades
129
+ * to an empty report with an explicit note (absence is UNKNOWN, not zero).
130
+ */
131
+ export declare function readRuntimePromptBudgetObservations(input: {
132
+ workspaceDir: string;
133
+ /** Reference clock for the future-timestamp guard (tests pass a fixed one). */
134
+ now?: Date;
135
+ }): RuntimeBudgetObservationReport;
136
+ /**
137
+ * Select the observation that may back a console forecast for `targetHost`.
138
+ * `absent` when the host has no observation at all; `stale` when the latest
139
+ * one is older than the freshness bound (the caller must fall back honestly).
140
+ */
141
+ export declare function selectRuntimeBudgetObservation(input: {
142
+ observations: readonly RuntimePromptBudgetObservation[];
143
+ targetHost?: PromptInjectionTargetHost;
144
+ now?: Date;
145
+ }): RuntimeBudgetObservationSelection;
146
+ /**
147
+ * Console-facing selection: one call gives the confirmed-host budget basis,
148
+ * the honest stale marker, and the per-host bases for unconfirmed routes.
149
+ * Reads the workspace's event logs; never throws.
150
+ */
151
+ export interface ConsoleRuntimeBudgetFacts {
152
+ /** Usable observation for the requested host (only when targetHost is given). */
153
+ selected?: RuntimePromptBudgetObservation;
154
+ /** Latest observation for the requested host that is too old to be current. */
155
+ stale?: RuntimePromptBudgetObservation;
156
+ /**
157
+ * Usable observations per host — used for unconfirmed per-host forecasts
158
+ * AND for a confirmed route whose host was resolved from workspace facts
159
+ * (no request-level targetHost). Each host only ever gets its own fact.
160
+ */
161
+ perHost: Map<PromptInjectionTargetHost, RuntimePromptBudgetObservation>;
162
+ /** Latest per-host observation that is too old to be presented as current. */
163
+ stalePerHost: Map<PromptInjectionTargetHost, RuntimePromptBudgetObservation>;
164
+ }
165
+ export declare function selectConsoleRuntimeBudgetFacts(input: {
166
+ workspaceDir: string;
167
+ targetHost?: PromptInjectionTargetHost;
168
+ now?: Date;
169
+ }): ConsoleRuntimeBudgetFacts;
170
+ /**
171
+ * Rebuild the budget resolution an observation stands for, so console
172
+ * projections run under the SAME resolution shape the real injection used.
173
+ * `fallbackDetail` is deliberately dropped: it is log-only context and never
174
+ * rides events or console payloads.
175
+ */
176
+ export declare function runtimeBudgetObservationToResolution(observation: RuntimePromptBudgetObservation): PromptBudgetResolution;
@@ -0,0 +1,449 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROMPT_BUDGET_POLICY_VERSION, RUNTIME_V2_PRINCIPLE_BUDGET, isValidContextWindowTokens, promptBudgetCharsForContextWindow, } from '@principles/core/runtime-v2';
4
+ import { isPromptInjectionTargetHost, } from './prompt-injection-projection.js';
5
+ /**
6
+ * PRI-1017: runtime budget OBSERVATION read model over the EXISTING
7
+ * injection-event JSONL (`<workspaceDir>/{.pd,.state}/logs/events_<date>.jsonl`,
8
+ * written by the OpenClaw EventLog and the Codex/WorkBuddy shared path via
9
+ * appendEventLogLine).
10
+ *
11
+ * Why this exists: SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 forbids the
12
+ * actual injection from running a dynamic budget while the Console forecasts
13
+ * under a different fixed 2000-char budget. The console holds no live host
14
+ * facts, but the RUNTIME persists the resolution it actually used —
15
+ * `runtime_v2_prompt_activations_injected` carries budgetChars, capacity
16
+ * provenance and hostKind. This module reads those facts back so console
17
+ * surfaces forecast under the same budget the last real injection used.
18
+ *
19
+ * Honesty rules this model enforces (SPEC §6.1/§7):
20
+ * - An observation is a FACT ABOUT THE PAST, never a live claim: callers must
21
+ * surface `observedAt`; the console labels it "most recent runtime
22
+ * observation", not a current confirmation.
23
+ * - No logs / no budget facts ⇒ absent, never a fabricated window; the caller
24
+ * keeps the honest fixed-fallback policy.
25
+ * - A latest observation older than RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS is
26
+ * `stale` — the caller must NOT present it as the current budget.
27
+ * - Observations bind to ONE host: OpenClaw's window is never used for a
28
+ * Codex or WorkBuddy forecast.
29
+ * - Untrusted input discipline: every line is parsed as `unknown` and
30
+ * narrowed with typeof guards + closed-set checks; structurally invalid
31
+ * lines are counted in `parseFailures` and skipped loudly, never guessed.
32
+ * - Events predating the model-aware policy carry no provenance fields; they
33
+ * are counted as `legacyEventsSkipped` and produce NO observation — a
34
+ * missing provenance field is "policy unknown", never a capacity claim.
35
+ * - Bounded I/O: only the newest bytes of the most recent daily files are read
36
+ * (tail window), the file cap applying PER candidate log directory, so a
37
+ * runaway or crafted log cannot make a Console request read an unbounded
38
+ * amount of data. Only bounded budget metadata is retained and returned;
39
+ * other event payloads are not cached.
40
+ */
41
+ /**
42
+ * Candidate directories for the runtime-v2 event log, in priority order.
43
+ * Shared with the RuleCode telemetry collector (ERR-031: both readers derive
44
+ * from one list — the v1 EventLog writer emits `.state/logs`, no production
45
+ * code ever created `.pd/logs`).
46
+ */
47
+ export const RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS = ['.pd/logs', '.state/logs'];
48
+ /**
49
+ * Bounded I/O: only the most recent daily files are scanned — PER candidate
50
+ * log directory (`RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS`), not globally. The
51
+ * cap is deliberately per directory: both directories describe the same
52
+ * workspace's log convention and either can hold the newest event for a given
53
+ * host (the reader takes latest-wins ACROSS them), so a global cap would let
54
+ * one directory's files starve the other and drop a live host observation.
55
+ * Worst case per read is therefore `RUNTIME_BUDGET_OBSERVATION_MAX_FILES ×
56
+ * RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS.length` files (14 today) and the same
57
+ * count × `RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES` bytes (56 MiB today).
58
+ */
59
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FILES = 7;
60
+ /**
61
+ * Bounded I/O: per file, only the newest bytes are read. The writer appends,
62
+ * so the latest observation per host lives at the TAIL; a file larger than
63
+ * this window is tail-read and its older prefix skipped (counted in
64
+ * `truncatedFiles`) instead of materialising an unbounded log in the console
65
+ * process. 4 MiB is ~3.4x the largest real day observed on 2026-10-21
66
+ * (WorkBuddy 1.17 MiB, Codex 0.87 MiB, OpenClaw 0.98 MiB across 7 files).
67
+ */
68
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES = 4 * 1024 * 1024;
69
+ /**
70
+ * Maximum age of an observation that may be presented as a CURRENT runtime
71
+ * fact. The runtime refreshes the observation on every prompt build, so an
72
+ * active workspace holds minute-old facts; beyond a day the model may have
73
+ * been switched and the window is no longer evidence about the present.
74
+ */
75
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
76
+ /**
77
+ * Clock-skew allowance for event timestamps. A writer clock ahead of ours is
78
+ * normal; an observation further in the future is not a fact about the
79
+ * present and must never be selected as the current budget.
80
+ */
81
+ export const RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
82
+ /** Upper bound on the envelope `ts` string; anything longer is not a timestamp. */
83
+ const OBSERVED_AT_MAX_LENGTH = 64;
84
+ /** Console-side bounded reason: a runtime observation exists but is too old. */
85
+ export const RUNTIME_BUDGET_OBSERVATION_STALE_REASON = 'runtime_budget_observation_stale';
86
+ function isRecord(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
89
+ function nonEmptyString(value) {
90
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
91
+ }
92
+ /** Windows paths are case-insensitive; compare them the way the OS does. */
93
+ function sameWorkspacePath(left, right) {
94
+ const resolvedLeft = path.resolve(left);
95
+ const resolvedRight = path.resolve(right);
96
+ return process.platform === 'win32'
97
+ ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
98
+ : resolvedLeft === resolvedRight;
99
+ }
100
+ function parseObservedAt(value) {
101
+ if (value.length > OBSERVED_AT_MAX_LENGTH)
102
+ return null;
103
+ const parsed = Date.parse(value);
104
+ return Number.isFinite(parsed) ? parsed : null;
105
+ }
106
+ const BUDGET_CAPACITY_SOURCES = new Set([
107
+ 'host_effective_window',
108
+ 'pi_catalog_exact',
109
+ 'legacy_fallback',
110
+ ]);
111
+ const BUDGET_CAPACITY_STATUSES = new Set(['confirmed', 'unknown']);
112
+ const BUDGET_FALLBACK_REASONS = new Set([
113
+ 'host_capacity_absent',
114
+ 'host_capacity_source_untrusted',
115
+ 'host_capacity_invalid_value',
116
+ 'catalog_unavailable',
117
+ 'model_identity_absent',
118
+ 'catalog_no_exact_match',
119
+ 'catalog_no_match_for_model_id',
120
+ 'catalog_ambiguous_model_id',
121
+ 'catalog_provider_absent',
122
+ 'catalog_metadata_invalid',
123
+ ]);
124
+ function isBudgetFallbackReason(value) {
125
+ return typeof value === 'string' && BUDGET_FALLBACK_REASONS.has(value);
126
+ }
127
+ /**
128
+ * Narrow one untrusted event line into an observation, or null when it does
129
+ * not carry a trustworthy model-aware budget fact. rc-1/rc-2: every field is
130
+ * guarded; a structurally valid event missing provenance is NOT an
131
+ * observation (see `legacyEventsSkipped` at the report level).
132
+ */
133
+ function narrowObservation(parsed, workspaceDir, nowMs) {
134
+ if (!isRecord(parsed))
135
+ return null;
136
+ if (nonEmptyString(parsed.type) !== 'runtime_v2_prompt_activations_injected')
137
+ return null;
138
+ const observedAt = nonEmptyString(parsed.ts);
139
+ const { data } = parsed;
140
+ if (observedAt === undefined || !isRecord(data))
141
+ return null;
142
+ const observedAtMs = parseObservedAt(observedAt);
143
+ if (observedAtMs === null)
144
+ return null;
145
+ // A timestamp beyond minor clock skew is not a fact about the present; it
146
+ // must not win the "latest observation" race or back a current forecast.
147
+ if (observedAtMs - nowMs > RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS)
148
+ return null;
149
+ // Workspace binding: the console model is workspace-scoped, so an event
150
+ // recorded under a different workspaceDir is not this workspace's fact.
151
+ const eventWorkspaceDir = nonEmptyString(data.workspaceDir);
152
+ if (eventWorkspaceDir === undefined || !sameWorkspacePath(eventWorkspaceDir, workspaceDir))
153
+ return null;
154
+ const rawHostKind = data.hostKind;
155
+ if (!isPromptInjectionTargetHost(rawHostKind))
156
+ return null;
157
+ const { budget } = data;
158
+ if (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)
159
+ return null;
160
+ // A budget computed under another policy version is not evidence about the
161
+ // CURRENT formula. Absent provenance is pre-policy, not "current by
162
+ // default": both are skipped, never reinterpreted.
163
+ if (data.budgetPolicyVersion !== PROMPT_BUDGET_POLICY_VERSION)
164
+ return null;
165
+ const rawSource = data.budgetCapacitySource;
166
+ const rawStatus = data.budgetCapacityStatus;
167
+ if (typeof rawSource !== 'string' || !BUDGET_CAPACITY_SOURCES.has(rawSource))
168
+ return null;
169
+ if (typeof rawStatus !== 'string' || !BUDGET_CAPACITY_STATUSES.has(rawStatus))
170
+ return null;
171
+ const rawWindow = data.budgetContextWindowTokens;
172
+ const rawReason = data.budgetFallbackReason;
173
+ if (rawReason !== undefined && !isBudgetFallbackReason(rawReason))
174
+ return null;
175
+ // Source/status/window/budget must describe ONE coherent resolution: a
176
+ // confirmed source needs a real window and exactly the budget the shared
177
+ // formula derives from it; the legacy fallback needs the legacy budget, no
178
+ // window, and a bounded reason. Anything else is not a trustworthy fact.
179
+ if (rawStatus === 'confirmed') {
180
+ if (rawSource !== 'host_effective_window' && rawSource !== 'pi_catalog_exact')
181
+ return null;
182
+ if (!isValidContextWindowTokens(rawWindow))
183
+ return null;
184
+ if (budget !== promptBudgetCharsForContextWindow(rawWindow))
185
+ return null;
186
+ if (rawReason !== undefined)
187
+ return null;
188
+ }
189
+ else {
190
+ if (rawSource !== 'legacy_fallback')
191
+ return null;
192
+ if (rawWindow !== undefined)
193
+ return null;
194
+ if (!isBudgetFallbackReason(rawReason))
195
+ return null;
196
+ if (budget !== RUNTIME_V2_PRINCIPLE_BUDGET)
197
+ return null;
198
+ }
199
+ return {
200
+ workspaceDir,
201
+ hostKind: rawHostKind,
202
+ budgetChars: budget,
203
+ capacitySource: rawSource,
204
+ capacityStatus: rawStatus,
205
+ contextWindowTokens: rawWindow ?? null,
206
+ fallbackReason: rawReason ?? null,
207
+ policyVersion: PROMPT_BUDGET_POLICY_VERSION,
208
+ observedAt,
209
+ };
210
+ }
211
+ /**
212
+ * Read at most the newest `RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES` of a
213
+ * log file. When the prefix is skipped, the partial first line after the cut
214
+ * is dropped so a half-written JSON line can never be parsed. Returns null
215
+ * when the file cannot be opened/read (the caller skips it loudly elsewhere).
216
+ */
217
+ function readBoundedLogText(filePath) {
218
+ let fd;
219
+ try {
220
+ fd = fs.openSync(filePath, 'r');
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ try {
226
+ const { size } = fs.fstatSync(fd);
227
+ const start = Math.max(0, size - RUNTIME_BUDGET_OBSERVATION_MAX_FILE_BYTES);
228
+ const length = size - start;
229
+ if (length <= 0)
230
+ return { text: '', truncated: false };
231
+ const buffer = Buffer.allocUnsafe(length);
232
+ let offset = 0;
233
+ while (offset < length) {
234
+ const read = fs.readSync(fd, buffer, offset, length - offset, start + offset);
235
+ if (read <= 0)
236
+ break;
237
+ offset += read;
238
+ }
239
+ let text = buffer.subarray(0, offset).toString('utf8');
240
+ if (start > 0) {
241
+ const newline = text.indexOf('\n');
242
+ text = newline === -1 ? '' : text.slice(newline + 1);
243
+ }
244
+ return { text, truncated: start > 0 };
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ finally {
250
+ try {
251
+ fs.closeSync(fd);
252
+ }
253
+ catch { /* fd already closed */ }
254
+ }
255
+ }
256
+ /**
257
+ * Read the most recent budget observation per host from the workspace's
258
+ * runtime-v2 event logs. Never throws: an unreadable log directory degrades
259
+ * to an empty report with an explicit note (absence is UNKNOWN, not zero).
260
+ */
261
+ export function readRuntimePromptBudgetObservations(input) {
262
+ const { workspaceDir } = input;
263
+ const nowMs = (input.now ?? new Date()).getTime();
264
+ const filesRead = [];
265
+ const lines = { count: 0 };
266
+ const parseFailures = { count: 0 };
267
+ const legacyEventsSkipped = { count: 0 };
268
+ const foreignPolicyEventsSkipped = { count: 0 };
269
+ const truncatedFiles = { count: 0 };
270
+ const latest = new Map();
271
+ for (const candidate of RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS) {
272
+ const logsRoot = path.resolve(workspaceDir, ...candidate.split('/'));
273
+ if (!fs.existsSync(logsRoot))
274
+ continue;
275
+ let canonicalRoot;
276
+ try {
277
+ canonicalRoot = fs.realpathSync(logsRoot);
278
+ }
279
+ catch {
280
+ continue;
281
+ }
282
+ let names;
283
+ try {
284
+ names = fs.readdirSync(logsRoot)
285
+ .filter((name) => name.startsWith('events_') && name.endsWith('.jsonl'))
286
+ .sort()
287
+ .slice(-RUNTIME_BUDGET_OBSERVATION_MAX_FILES);
288
+ }
289
+ catch {
290
+ // Unreadable directory contributes nothing; the note stays explicit.
291
+ continue;
292
+ }
293
+ for (const name of names) {
294
+ // Containment guard: only bare event filenames inside the resolved
295
+ // logs root are ever read — a crafted name cannot escape the directory.
296
+ // Canonicalize BOTH sides so an in-logs symlink cannot redirect the
297
+ // read outside the workspace log directory (EP-08).
298
+ const filePath = path.resolve(logsRoot, name);
299
+ if (!filePath.startsWith(logsRoot + path.sep) || path.basename(name) !== name)
300
+ continue;
301
+ let canonicalFile;
302
+ try {
303
+ canonicalFile = fs.realpathSync(filePath);
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ if (!canonicalFile.startsWith(canonicalRoot + path.sep))
309
+ continue;
310
+ filesRead.push(canonicalFile);
311
+ const bounded = readBoundedLogText(canonicalFile);
312
+ if (bounded === null)
313
+ continue;
314
+ if (bounded.truncated)
315
+ truncatedFiles.count += 1;
316
+ for (const line of bounded.text.split('\n')) {
317
+ if (line.trim().length === 0)
318
+ continue;
319
+ lines.count += 1;
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(line);
323
+ }
324
+ catch {
325
+ parseFailures.count += 1;
326
+ continue;
327
+ }
328
+ if (isRecord(parsed) && parsed.type === 'runtime_v2_prompt_activations_injected' && isRecord(parsed.data)) {
329
+ const { data } = parsed;
330
+ const rawPolicyVersion = data.budgetPolicyVersion;
331
+ if (rawPolicyVersion !== undefined && rawPolicyVersion !== PROMPT_BUDGET_POLICY_VERSION) {
332
+ // A budget computed under a DIFFERENT policy version is not
333
+ // evidence about the current formula — counted, not reinterpreted.
334
+ foreignPolicyEventsSkipped.count += 1;
335
+ continue;
336
+ }
337
+ const observation = narrowObservation(parsed, workspaceDir, nowMs);
338
+ if (observation === null) {
339
+ // A pre-policy event, a future/invalid timestamp, incoherent
340
+ // provenance or an unusable binding — counted, never guessed.
341
+ legacyEventsSkipped.count += 1;
342
+ continue;
343
+ }
344
+ const previous = latest.get(observation.hostKind);
345
+ // Compare PARSED instants, not strings: a longer/invalid timestamp
346
+ // must not shadow a real observation lexicographically.
347
+ if (previous === undefined || Date.parse(observation.observedAt) > Date.parse(previous.observedAt)) {
348
+ latest.set(observation.hostKind, observation);
349
+ }
350
+ continue;
351
+ }
352
+ if (isRecord(parsed) && parsed.type === 'runtime_v2_prompt_activations_injected' && !isRecord(parsed.data)) {
353
+ parseFailures.count += 1;
354
+ }
355
+ }
356
+ }
357
+ }
358
+ return {
359
+ observations: [...latest.values()],
360
+ filesRead,
361
+ lines: lines.count,
362
+ parseFailures: parseFailures.count,
363
+ legacyEventsSkipped: legacyEventsSkipped.count,
364
+ foreignPolicyEventsSkipped: foreignPolicyEventsSkipped.count,
365
+ truncatedFiles: truncatedFiles.count,
366
+ note: 'observations are PAST runtime facts bound to one host; absence means UNKNOWN (never a fabricated window); events without model-aware budget provenance, or computed under a different policy version, are skipped, not reinterpreted; the bounded read takes the newest daily files per candidate log directory and files larger than the tail window are counted in truncatedFiles (older prefix skipped, newest event per host still inside the window)',
367
+ };
368
+ }
369
+ /** Latest-wins ordering that treats unparseable timestamps as older, not newer. */
370
+ function compareObservedAt(left, right) {
371
+ const leftMs = Date.parse(left);
372
+ const rightMs = Date.parse(right);
373
+ if (!Number.isFinite(leftMs))
374
+ return Number.isFinite(rightMs) ? -1 : 0;
375
+ if (!Number.isFinite(rightMs))
376
+ return 1;
377
+ return leftMs - rightMs;
378
+ }
379
+ /**
380
+ * Select the observation that may back a console forecast for `targetHost`.
381
+ * `absent` when the host has no observation at all; `stale` when the latest
382
+ * one is older than the freshness bound (the caller must fall back honestly).
383
+ */
384
+ export function selectRuntimeBudgetObservation(input) {
385
+ const now = input.now ?? new Date();
386
+ const candidates = input.targetHost === undefined
387
+ ? [...input.observations]
388
+ : input.observations.filter((observation) => observation.hostKind === input.targetHost);
389
+ let latest;
390
+ for (const candidate of candidates) {
391
+ if (latest === undefined || compareObservedAt(candidate.observedAt, latest.observedAt) > 0)
392
+ latest = candidate;
393
+ }
394
+ if (latest === undefined)
395
+ return { kind: 'absent' };
396
+ const observedAt = Date.parse(latest.observedAt);
397
+ if (!Number.isFinite(observedAt)
398
+ || observedAt - now.getTime() > RUNTIME_BUDGET_OBSERVATION_MAX_FUTURE_SKEW_MS
399
+ || now.getTime() - observedAt > RUNTIME_BUDGET_OBSERVATION_MAX_AGE_MS) {
400
+ return { kind: 'stale', observation: latest };
401
+ }
402
+ return { kind: 'usable', observation: latest };
403
+ }
404
+ export function selectConsoleRuntimeBudgetFacts(input) {
405
+ const report = readRuntimePromptBudgetObservations({ workspaceDir: input.workspaceDir, now: input.now });
406
+ const perHost = new Map();
407
+ const stalePerHost = new Map();
408
+ for (const observation of report.observations) {
409
+ const selection = selectRuntimeBudgetObservation({
410
+ observations: report.observations,
411
+ targetHost: observation.hostKind,
412
+ now: input.now,
413
+ });
414
+ if (selection.kind === 'usable')
415
+ perHost.set(observation.hostKind, selection.observation);
416
+ else if (selection.kind === 'stale')
417
+ stalePerHost.set(observation.hostKind, selection.observation);
418
+ }
419
+ if (input.targetHost === undefined) {
420
+ return { perHost, stalePerHost };
421
+ }
422
+ const selection = selectRuntimeBudgetObservation({
423
+ observations: report.observations,
424
+ targetHost: input.targetHost,
425
+ now: input.now,
426
+ });
427
+ return {
428
+ ...(selection.kind === 'usable' ? { selected: selection.observation } : {}),
429
+ ...(selection.kind === 'stale' ? { stale: selection.observation } : {}),
430
+ perHost,
431
+ stalePerHost,
432
+ };
433
+ }
434
+ /**
435
+ * Rebuild the budget resolution an observation stands for, so console
436
+ * projections run under the SAME resolution shape the real injection used.
437
+ * `fallbackDetail` is deliberately dropped: it is log-only context and never
438
+ * rides events or console payloads.
439
+ */
440
+ export function runtimeBudgetObservationToResolution(observation) {
441
+ return {
442
+ budgetChars: observation.budgetChars,
443
+ capacitySource: observation.capacitySource,
444
+ capacityStatus: observation.capacityStatus,
445
+ contextWindowTokens: observation.contextWindowTokens,
446
+ policyVersion: observation.policyVersion ?? PROMPT_BUDGET_POLICY_VERSION,
447
+ ...(observation.fallbackReason !== null ? { fallbackReason: observation.fallbackReason } : {}),
448
+ };
449
+ }
@@ -232,4 +232,20 @@ export declare function resolveLivePromptInjectionProjection(input: {
232
232
  targetHost?: PromptInjectionTargetHost;
233
233
  excludePrincipleIds?: ReadonlySet<string>;
234
234
  roundKey?: number;
235
+ /**
236
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 / PRI-1017: budget resolution
237
+ * the confirmed projection must run under. Console callers pass the
238
+ * resolution rebuilt from the workspace's most recent runtime budget
239
+ * OBSERVATION (`runtimeBudgetObservationToResolution`) so the forecast uses
240
+ * the same budget the last real injection used. Absent → the fixed-fallback
241
+ * default (the honest "no runtime capacity confirmed" policy).
242
+ */
243
+ promptBudget?: PromptBudgetResolution;
244
+ /**
245
+ * PRI-1017: per-host budget resolutions for the unconfirmed per-host
246
+ * forecasts. Each host's projection runs under THAT host's observation —
247
+ * never another host's window. Absent per host → that host's honest
248
+ * fixed-fallback default.
249
+ */
250
+ perHostPromptBudget?: Partial<Record<PromptInjectionTargetHost, PromptBudgetResolution>>;
235
251
  }): Promise<LivePromptInjectionProjectionResolution>;
@@ -334,20 +334,35 @@ export async function buildLivePromptInjectionProjection(input) {
334
334
  };
335
335
  }
336
336
  export async function resolveLivePromptInjectionProjection(input) {
337
- const decision = resolvePromptInjectionRouteDecision(input);
337
+ const { promptBudget, perHostPromptBudget, ...projectionInput } = input;
338
+ const decision = resolvePromptInjectionRouteDecision(projectionInput);
338
339
  if (decision.status === 'confirmed') {
340
+ // PRI-1017: the route can be confirmed by WORKSPACE FACTS without any
341
+ // request-level targetHost (a single-host workspace). The caller then has
342
+ // no `promptBudget`, but it does have that host's own observation in the
343
+ // per-host map — use it. A flag-shared route with no hostKind stays on
344
+ // the honest fallback: no host was identified, so no host's window may
345
+ // be borrowed.
346
+ const decidedBudget = promptBudget
347
+ ?? (decision.hostKind !== undefined ? perHostPromptBudget?.[decision.hostKind] : undefined);
339
348
  const projection = await buildLivePromptInjectionProjection({
340
- ...input,
349
+ ...projectionInput,
341
350
  targetHost: decision.hostKind,
351
+ ...(decidedBudget !== undefined ? { promptBudget: decidedBudget } : {}),
342
352
  });
343
353
  return { status: 'confirmed', decision, projection };
344
354
  }
345
355
  const hosts = decision.perHost?.map((entry) => entry.hostKind) ?? [...PROMPT_INJECTION_TARGET_HOSTS];
346
356
  const perHost = [];
347
357
  for (const hostKind of hosts) {
358
+ const hostBudget = perHostPromptBudget?.[hostKind];
348
359
  perHost.push({
349
360
  hostKind,
350
- projection: await buildLivePromptInjectionProjection({ ...input, targetHost: hostKind }),
361
+ projection: await buildLivePromptInjectionProjection({
362
+ ...projectionInput,
363
+ targetHost: hostKind,
364
+ ...(hostBudget !== undefined ? { promptBudget: hostBudget } : {}),
365
+ }),
351
366
  });
352
367
  }
353
368
  return { status: 'unconfirmed', decision, perHost };
@@ -1,4 +1,4 @@
1
- import { type PromptBudgetTierFit } from '@principles/core/runtime-v2';
1
+ import { type PromptBudgetCapacitySource, type PromptBudgetCapacityStatus, type PromptBudgetFallbackReason, type PromptBudgetResolution, type PromptBudgetTierFit } from '@principles/core/runtime-v2';
2
2
  import { type PromptInjectionBudgetScope, type PromptInjectionRouteDecision, type PromptInjectionRouteName, type PromptInjectionTargetHost } from './prompt-injection-projection.js';
3
3
  /**
4
4
  * SPEC PD_PROMPT_CAPACITY_V1 R-A3: per-live-activation injection status for
@@ -45,6 +45,18 @@ export interface PromptActivationInjectionStatus {
45
45
  tierFit: PromptBudgetTierFit;
46
46
  }[];
47
47
  }
48
+ /**
49
+ * PRI-1017: provenance of the budget the rows were measured against, copied
50
+ * from the SAME resolution the projection ran under. Optional on every field
51
+ * so a caller can degrade without inventing a capacity fact.
52
+ */
53
+ export interface PromptActivationBudgetProvenance {
54
+ capacitySource?: PromptBudgetCapacitySource;
55
+ capacityStatus?: PromptBudgetCapacityStatus;
56
+ contextWindowTokens?: number;
57
+ policyVersion?: string;
58
+ fallbackReason?: PromptBudgetFallbackReason;
59
+ }
48
60
  export interface PromptActivationInjectionStatusReport {
49
61
  route: PromptInjectionRouteDecision;
50
62
  /** Empty when injection is globally unavailable (prompt flag off / state.db missing) — see `globalReason`. */
@@ -52,8 +64,23 @@ export interface PromptActivationInjectionStatusReport {
52
64
  warnings: string[];
53
65
  /** Present when no status could be computed at all (fail-loud degradation, rc-9). */
54
66
  globalReason?: string;
67
+ /** Present for a confirmed route: the budget basis every row was measured under. */
68
+ budgetProvenance?: PromptActivationBudgetProvenance;
55
69
  }
56
70
  export declare function readPromptActivationInjectionStatuses(input: {
57
71
  workspaceDir: string;
58
72
  targetHost?: PromptInjectionTargetHost;
73
+ /**
74
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1 / PRI-1017: budget resolution
75
+ * the status projection must run under — console callers pass the
76
+ * resolution rebuilt from the workspace's most recent runtime budget
77
+ * observation. Absent → the honest fixed-fallback default.
78
+ */
79
+ promptBudget?: PromptBudgetResolution;
80
+ /**
81
+ * PRI-1017: per-host observations for a route confirmed by WORKSPACE FACTS
82
+ * (no request-level targetHost). The projection uses only the resolved
83
+ * host's own budget; an unidentified shared route stays on the fallback.
84
+ */
85
+ perHostPromptBudget?: Partial<Record<PromptInjectionTargetHost, PromptBudgetResolution>>;
59
86
  }): Promise<PromptActivationInjectionStatusReport>;
@@ -103,9 +103,12 @@ export async function readPromptActivationInjectionStatuses(input) {
103
103
  });
104
104
  return { route, statuses, warnings };
105
105
  }
106
+ const decidedBudget = input.promptBudget
107
+ ?? (route.hostKind !== undefined ? input.perHostPromptBudget?.[route.hostKind] : undefined);
106
108
  const projection = await buildLivePromptInjectionProjection({
107
109
  workspaceDir: input.workspaceDir,
108
110
  targetHost: route.hostKind,
111
+ ...(decidedBudget !== undefined ? { promptBudget: decidedBudget } : {}),
109
112
  });
110
113
  warnings.push(...projection.warnings);
111
114
  const statuses = rows.map((row) => {
@@ -122,12 +125,12 @@ export async function readPromptActivationInjectionStatuses(input) {
122
125
  const budgetScope = projection.budgetScope;
123
126
  const serialization = projection.route === 'shared_render' ? 'full directive render' : 'list entry';
124
127
  // Tier verdict FIRST (SPEC §6.3): the projection budget on this surface
125
- // is usually the fixed fallback (2000) — the console holds no runtime
126
- // model facts. An entry in (MIN, fallback] fits THAT estimate and may
127
- // even sit in the current window, but it exceeds the measured floor, so
128
- // on a 32K/64K model (budget = floor) it would NOT inject. Reporting it
129
- // as an unconditional `injectable` would contradict the approval-time
130
- // `model_dependent` verdict for the very same artifact.
128
+ // is the fixed fallback (2000) unless the caller passed a runtime budget
129
+ // OBSERVATION (PRI-1017) — and even then an entry in (MIN, fallback]
130
+ // exceeds the measured floor, so on a 32K/64K model (budget = floor) it
131
+ // would NOT inject. Reporting it as an unconditional `injectable` would
132
+ // contradict the approval-time `model_dependent` verdict for the very
133
+ // same artifact.
131
134
  const tierFit = classifySingleItemBudgetTier(costChars);
132
135
  if (tierFit === 'exceeds_max_tier') {
133
136
  return {
@@ -166,5 +169,16 @@ export async function readPromptActivationInjectionStatuses(input) {
166
169
  route: projection.route,
167
170
  };
168
171
  });
169
- return { route, statuses, warnings };
172
+ return {
173
+ route,
174
+ statuses,
175
+ warnings,
176
+ budgetProvenance: {
177
+ ...(projection.budgetCapacitySource !== undefined ? { capacitySource: projection.budgetCapacitySource } : {}),
178
+ ...(projection.budgetCapacityStatus !== undefined ? { capacityStatus: projection.budgetCapacityStatus } : {}),
179
+ ...(typeof projection.budgetContextWindowTokens === 'number' ? { contextWindowTokens: projection.budgetContextWindowTokens } : {}),
180
+ ...(projection.budgetPolicyVersion !== undefined ? { policyVersion: projection.budgetPolicyVersion } : {}),
181
+ ...(projection.budgetFallbackReason !== undefined ? { fallbackReason: projection.budgetFallbackReason } : {}),
182
+ },
183
+ };
170
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",