@principles/host-runtime 0.10.4 → 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.
@@ -1,4 +1,4 @@
1
- import { type ActivatedPrinciple, type PromptSelectionPolicy } from '@principles/core/runtime-v2';
1
+ import { type ActivatedPrinciple, type PromptBudgetResolution, type PromptSelectionPolicy } from '@principles/core/runtime-v2';
2
2
  /** PRI-904 SPEC §10: bounded diagnostic id lists (mirrors the core selector's cap of 16). */
3
3
  export declare const MAX_SHARED_DIAGNOSTIC_IDS = 16;
4
4
  export interface ActivePrinciplePromptResult {
@@ -23,6 +23,20 @@ export interface ActivePrinciplePromptResult {
23
23
  droppedActivationIds?: string[];
24
24
  /** PRI-904 (bounded, max 16): activation ids that cannot fit even in an empty payload. */
25
25
  oversizedActivationIds?: string[];
26
+ /**
27
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: provenance of the `budget` this
28
+ * selection ran under (capacity source/status + the resolved context
29
+ * window). Present on every result; `legacy_fallback` + `unknown` means
30
+ * no model capacity could be confirmed and the fixed budget was used.
31
+ */
32
+ budgetCapacitySource?: PromptBudgetResolution['capacitySource'];
33
+ budgetCapacityStatus?: PromptBudgetResolution['capacityStatus'];
34
+ budgetContextWindowTokens?: number | null;
35
+ budgetPolicyVersion?: PromptBudgetResolution['policyVersion'];
36
+ /** SPEC §7 v0.2: bounded fallback reason code; present only on legacy_fallback. */
37
+ budgetFallbackReason?: PromptBudgetResolution['fallbackReason'];
38
+ /** Non-sensitive log context (provider/model ids); never emitted to events. */
39
+ budgetFallbackDetail?: string;
26
40
  }
27
41
  export interface PromptActivationCandidates {
28
42
  principles: ActivatedPrinciple[];
@@ -52,4 +66,12 @@ export declare function buildActivePrinciplePromptContext(input: {
52
66
  * characterization baseline).
53
67
  */
54
68
  roundKey?: number;
69
+ /**
70
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1: the budget resolution for
71
+ * THIS request, resolved once by the entry point that holds the model
72
+ * facts. Absent → the resolver's no-facts default (legacy fixed 2000,
73
+ * capacityStatus unknown) — the historical behavior, used by callers with
74
+ * no model context (console projections, WorkBuddy without model facts).
75
+ */
76
+ promptBudget?: PromptBudgetResolution;
55
77
  }): Promise<ActivePrinciplePromptResult>;
@@ -1,4 +1,4 @@
1
- import { RUNTIME_V2_PRINCIPLE_BUDGET, SqliteActivationStateStore, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, filterPromptActivations, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, } from '@principles/core/runtime-v2';
1
+ import { SqliteActivationStateStore, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, filterPromptActivations, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, resolvePromptBudget, } from '@principles/core/runtime-v2';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { escapeXml } from '@principles/core/prompt-builder';
@@ -81,13 +81,23 @@ export async function readPromptActivationCandidates(input) {
81
81
  return { principles, excludedPrincipleIds, warnings, aborted: false, selfReportEnabled, abstractionLayerEnabled };
82
82
  }
83
83
  export async function buildActivePrinciplePromptContext(input) {
84
+ // One resolution per request (SPEC §6.1): the caller's pre-resolved budget
85
+ // or the fixed-fallback default — never a per-line constant read.
86
+ const promptBudget = input.promptBudget ?? resolvePromptBudget();
87
+ const { budgetChars } = promptBudget;
84
88
  const { principles, excludedPrincipleIds, warnings, aborted, selfReportEnabled } = await readPromptActivationCandidates(input);
85
89
  if (aborted) {
86
90
  return {
87
91
  additionalContext: '', principleIds: [], activationIds: [], artifactIds: [], warnings,
88
- budget: RUNTIME_V2_PRINCIPLE_BUDGET, truncated: false, excludedPrincipleIds,
92
+ budget: budgetChars, truncated: false, excludedPrincipleIds,
89
93
  excludedCount: excludedPrincipleIds.length, allValidatedPrinciplesExcluded: false,
90
94
  selectionPolicy: 'legacy_fifo_prefix_v1', eligibleCount: 0,
95
+ budgetCapacitySource: promptBudget.capacitySource,
96
+ budgetCapacityStatus: promptBudget.capacityStatus,
97
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
98
+ budgetPolicyVersion: promptBudget.policyVersion,
99
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
100
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
91
101
  };
92
102
  }
93
103
  const included = [];
@@ -109,14 +119,14 @@ export async function buildActivePrinciplePromptContext(input) {
109
119
  const droppedActivationIds = [];
110
120
  const oversizedActivationIds = [];
111
121
  const standaloneFits = (principle) => renderPrinciplesToDirectives([principle], new Set([principle.principleId]), { escapeFn: escapeXml, selfReportInstruction: selfReportEnabled }).length
112
- <= RUNTIME_V2_PRINCIPLE_BUDGET;
122
+ <= budgetChars;
113
123
  const scanOrder = rotationStartIndex === undefined
114
124
  ? principles
115
125
  : principles.slice(rotationStartIndex).concat(principles.slice(0, rotationStartIndex));
116
126
  for (const principle of scanOrder) {
117
127
  const candidate = [...included, principle];
118
128
  const candidateContext = renderPrinciplesToDirectives(candidate, new Set(candidate.map((entry) => entry.principleId)), { escapeFn: escapeXml, selfReportInstruction: selfReportEnabled });
119
- if (candidateContext.length > RUNTIME_V2_PRINCIPLE_BUDGET) {
129
+ if (candidateContext.length > budgetChars) {
120
130
  if (standaloneFits(principle)) {
121
131
  // Fits alone but not now — normal budget truncation.
122
132
  truncated = true;
@@ -144,7 +154,7 @@ export async function buildActivePrinciplePromptContext(input) {
144
154
  activationIds: included.map((principle) => principle.activationId),
145
155
  artifactIds: included.map((principle) => principle.artifactId),
146
156
  warnings,
147
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
157
+ budget: budgetChars,
148
158
  truncated,
149
159
  excludedPrincipleIds,
150
160
  excludedCount: excludedPrincipleIds.length,
@@ -155,5 +165,11 @@ export async function buildActivePrinciplePromptContext(input) {
155
165
  ...(rotationStartIndex !== undefined ? { rotationStartIndex } : {}),
156
166
  ...(droppedActivationIds.length > 0 ? { droppedActivationIds } : {}),
157
167
  ...(oversizedActivationIds.length > 0 ? { oversizedActivationIds } : {}),
168
+ budgetCapacitySource: promptBudget.capacitySource,
169
+ budgetCapacityStatus: promptBudget.capacityStatus,
170
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
171
+ budgetPolicyVersion: promptBudget.policyVersion,
172
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
173
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
158
174
  };
159
175
  }
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
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isHostEvent, isHostEventResult, } from '@principles/core/host';
3
- import { RUNTIME_V2_PRINCIPLE_BUDGET } from '@principles/core/runtime-v2';
3
+ import { getPromptModelCatalog, resolvePromptBudget } from '@principles/core/runtime-v2';
4
4
  import { buildActivePrinciplePromptContext } from './active-principle-prompt.js';
5
5
  import { createProductionRuleHostGate } from './production-rulehost-gate.js';
6
6
  import { createProductionPainEvidenceHandler } from './production-pain-evidence.js';
@@ -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';
@@ -211,6 +215,26 @@ export function createProductionHostRuntime(options = {}) {
211
215
  const prompt = await buildActivePrinciplePromptContext({
212
216
  workspaceDir: event.context.workspaceDir,
213
217
  excludePrincipleIds: options.promptExcludePrincipleIds?.(event),
218
+ promptBudget: resolvePromptBudget({
219
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: resolve ONCE per request
220
+ // from the host's own hook facts (OpenClaw effective window /
221
+ // Codex+WorkBuddy model id) against the installed local Pi AI
222
+ // catalog — no network, no LLM call. Missing/untrusted facts
223
+ // resolve to the legacy fixed budget with an observable reason.
224
+ hostCapacity: event.context.modelContext === undefined
225
+ ? undefined
226
+ : {
227
+ contextTokenBudget: event.context.modelContext.contextTokenBudget,
228
+ contextWindowSource: event.context.modelContext.contextWindowSource,
229
+ },
230
+ modelIdentity: event.context.modelContext === undefined
231
+ ? undefined
232
+ : {
233
+ providerId: event.context.modelContext.modelProviderId,
234
+ modelId: event.context.modelContext.modelId,
235
+ },
236
+ catalog: getPromptModelCatalog(),
237
+ }),
214
238
  });
215
239
  // PRI-750: record the injection event on the shared path with the host
216
240
  // turn id (Codex turn_id → runId) so receipt events carry a turn-level
@@ -231,11 +255,22 @@ export function createProductionHostRuntime(options = {}) {
231
255
  injectedCount: prompt.principleIds.length,
232
256
  skippedWarnings: prompt.warnings,
233
257
  injectedCharCount: prompt.additionalContext.length,
234
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
258
+ budget: prompt.budget,
235
259
  ...(options.hostKind === 'openclaw' || options.hostKind === 'codex' || options.hostKind === 'workbuddy' ? { hostKind: options.hostKind } : {}),
236
260
  eventId: randomUUID(),
237
261
  ...(prompt.truncated !== undefined ? { v2Truncated: prompt.truncated } : {}),
238
262
  ...(event.context.turnId !== undefined ? { runId: event.context.turnId } : {}),
263
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: budget provenance — the
264
+ // recorded budget is the one THIS injection actually ran under
265
+ // (prompt.budget), never a constant restated after the fact.
266
+ ...(prompt.budgetPolicyVersion !== undefined ? { budgetPolicyVersion: prompt.budgetPolicyVersion } : {}),
267
+ ...(prompt.budgetCapacitySource !== undefined ? { budgetCapacitySource: prompt.budgetCapacitySource } : {}),
268
+ ...(prompt.budgetCapacityStatus !== undefined ? { budgetCapacityStatus: prompt.budgetCapacityStatus } : {}),
269
+ ...(prompt.budgetContextWindowTokens != null ? { budgetContextWindowTokens: prompt.budgetContextWindowTokens } : {}),
270
+ // SPEC §7 v0.2: the bounded fallback reason code rides the event so
271
+ // operators can tell model-identity absence from catalog ambiguity
272
+ // etc. without touching logs. Detail stays out of events.
273
+ ...(prompt.budgetFallbackReason !== undefined ? { budgetFallbackReason: prompt.budgetFallbackReason } : {}),
239
274
  // PRI-904: selection diagnostics (bounded, optional). On this route
240
275
  // the policy is legacy_fifo_prefix_v1 and NO rotation provenance is
241
276
  // emitted — a fair claim would be untrue here (see above).
@@ -1,4 +1,4 @@
1
- import { type ActivatedPrinciple } from '@principles/core/runtime-v2';
1
+ import { type ActivatedPrinciple, type PromptBudgetTierFit } from '@principles/core/runtime-v2';
2
2
  import { PROMPT_INJECTION_UNIT, type PromptInjectionBudgetScope, type PromptInjectionRouteDecision, type PromptInjectionRouteName, type PromptInjectionTargetHost } from './prompt-injection-projection.js';
3
3
  /**
4
4
  * SPEC PD_PROMPT_CAPACITY_V1 R-B2: ONE route-aware single-artifact capacity
@@ -25,18 +25,46 @@ export type PromptArtifactDeliverability = {
25
25
  route: PromptInjectionRouteName;
26
26
  hostKind?: PromptInjectionTargetHost;
27
27
  basis?: PromptInjectionRouteDecision['basis'];
28
+ /**
29
+ * The budget this judgment is binding against: the MINIMUM supported
30
+ * tier — a deliverable here fits under EVERY supported budget
31
+ * (SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.3), so the strictest tier is
32
+ * the honest evidence.
33
+ */
28
34
  budget: number;
35
+ budgetMinChars: number;
36
+ budgetMaxChars: number;
29
37
  /** Single-item cost in UTF-16 code units under the bound route's serializer. */
30
38
  costChars: number;
31
39
  budgetScope: PromptInjectionBudgetScope;
32
40
  unit: typeof PROMPT_INJECTION_UNIT;
41
+ } | {
42
+ /**
43
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.3: the artifact fits under
44
+ * SOME supported budgets but not all — whether it injects depends on the
45
+ * runtime model's context window. NOT a promise of injection and NOT a
46
+ * permanent refusal; approval flow proceeds unchanged.
47
+ */
48
+ status: 'model_dependent';
49
+ route: PromptInjectionRouteName;
50
+ hostKind?: PromptInjectionTargetHost;
51
+ basis?: PromptInjectionRouteDecision['basis'];
52
+ budgetMinChars: number;
53
+ budgetMaxChars: number;
54
+ costChars: number;
55
+ budgetScope: PromptInjectionBudgetScope;
56
+ unit: typeof PROMPT_INJECTION_UNIT;
57
+ nextAction: string;
33
58
  } | {
34
59
  status: 'undeliverable';
35
60
  reason: 'single_item_exceeds_budget';
36
61
  route: PromptInjectionRouteName;
37
62
  hostKind?: PromptInjectionTargetHost;
38
63
  basis?: PromptInjectionRouteDecision['basis'];
64
+ /** The budget this judgment is binding against: the MAXIMUM supported tier. */
39
65
  budget: number;
66
+ budgetMinChars: number;
67
+ budgetMaxChars: number;
40
68
  costChars: number;
41
69
  overByChars: number;
42
70
  budgetScope: PromptInjectionBudgetScope;
@@ -47,10 +75,10 @@ export type PromptArtifactDeliverability = {
47
75
  /** Stable machine-readable reason (route facts or artifact content unresolvable). */
48
76
  reason: string;
49
77
  nextAction: string;
50
- /** Route-unconfirmed case: per-host single-item fit so the UI can show both. */
78
+ /** Route-unconfirmed case: per-host single-item tier fit so the UI can show both. */
51
79
  perHost?: readonly {
52
80
  hostKind: PromptInjectionTargetHost;
53
- fits: boolean;
81
+ tierFit: PromptBudgetTierFit;
54
82
  costChars: number;
55
83
  }[];
56
84
  };
@@ -72,11 +100,16 @@ export declare function resolvePromptCandidatePrinciple(artifact: PromptDelivera
72
100
  * lines). For an entry that does NOT fit, trimToBudget returns only the
73
101
  * header line, so the would-be cost is reconstructed from the header line it
74
102
  * DID return plus the real entry line (same format authority, listEntryLine).
103
+ *
104
+ * The budget parameter defaults to the legacy fixed budget: approval-time
105
+ * callers hold no runtime model facts, and SPEC §6.3 requires the fixed
106
+ * fallback — never an invented capacity — when none can be confirmed.
75
107
  */
76
108
  export declare function computePromptSingleItemFit(input: {
77
109
  principle: ActivatedPrinciple;
78
110
  route: PromptInjectionRouteName;
79
111
  selfReportEnabled: boolean;
112
+ budget?: number;
80
113
  }): {
81
114
  fits: boolean;
82
115
  costChars: number;
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { RUNTIME_V2_PRINCIPLE_BUDGET, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, listEntryLine, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, trimToBudget, } from '@principles/core/runtime-v2';
3
+ import { PROMPT_BUDGET_FALLBACK_CHARS, PROMPT_BUDGET_MAX_CHARS, PROMPT_BUDGET_MIN_CHARS, SqliteConnection, SqlitePIArtifactStore, classifySingleItemBudgetTier, computeFeatureFlagsFromConfig, listEntryLine, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, trimToBudget, } from '@principles/core/runtime-v2';
4
4
  import { escapeXml } from '@principles/core/prompt-builder';
5
5
  import { loadPdConfigForPlugin } from './pd-config.js';
6
6
  import { PROMPT_INJECTION_TARGET_HOSTS, PROMPT_INJECTION_UNIT, promptRouteForHost, resolvePromptInjectionRouteDecision, } from './prompt-injection-projection.js';
@@ -38,13 +38,18 @@ function selfReportEnabledFor(workspaceDir) {
38
38
  * lines). For an entry that does NOT fit, trimToBudget returns only the
39
39
  * header line, so the would-be cost is reconstructed from the header line it
40
40
  * DID return plus the real entry line (same format authority, listEntryLine).
41
+ *
42
+ * The budget parameter defaults to the legacy fixed budget: approval-time
43
+ * callers hold no runtime model facts, and SPEC §6.3 requires the fixed
44
+ * fallback — never an invented capacity — when none can be confirmed.
41
45
  */
42
46
  export function computePromptSingleItemFit(input) {
47
+ const budget = input.budget ?? PROMPT_BUDGET_FALLBACK_CHARS;
43
48
  if (input.route === 'shared_render') {
44
49
  const render = renderPrinciplesToDirectives([input.principle], new Set([input.principle.principleId]), { escapeFn: escapeXml, selfReportInstruction: input.selfReportEnabled });
45
- return { fits: render.length <= RUNTIME_V2_PRINCIPLE_BUDGET, costChars: render.length };
50
+ return { fits: render.length <= budget, costChars: render.length };
46
51
  }
47
- const trimmed = trimToBudget([input.principle], RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml);
52
+ const trimmed = trimToBudget([input.principle], budget, escapeXml);
48
53
  const fits = trimmed.injectedIds.has(input.principle.principleId);
49
54
  const costChars = trimmed.lines.length > 1
50
55
  ? trimmed.lines.join('\n').length
@@ -52,6 +57,7 @@ export function computePromptSingleItemFit(input) {
52
57
  return { fits, costChars };
53
58
  }
54
59
  const UNDELIVERABLE_NEXT_ACTION = 'shorten the principle statement and resubmit, or use the activations page "modify to injectable version" entry to create a replacement artifact (the approval facts are preserved)';
60
+ const MODEL_DEPENDENT_NEXT_ACTION = 'this principle fits the injection budget only on models with a larger context window; it may be excluded at runtime on smaller-context models — the approval itself is unaffected';
55
61
  /**
56
62
  * Precheck one artifact against the workspace's effective injection route.
57
63
  * Pure read — no state mutation, safe to call before any governance write.
@@ -78,7 +84,7 @@ export async function checkPromptArtifactDeliverability(input) {
78
84
  targetHost: input.targetHost,
79
85
  });
80
86
  if (decision.status === 'confirmed' && decision.route !== undefined) {
81
- const { fits, costChars } = computePromptSingleItemFit({
87
+ const { costChars } = computePromptSingleItemFit({
82
88
  principle: resolved.principle,
83
89
  route: decision.route,
84
90
  selfReportEnabled,
@@ -86,44 +92,56 @@ export async function checkPromptArtifactDeliverability(input) {
86
92
  const budgetScope = decision.route === 'shared_render'
87
93
  ? 'full_directive_context'
88
94
  : 'selected_lines';
89
- if (fits) {
95
+ const shared = {
96
+ route: decision.route,
97
+ ...(decision.hostKind !== undefined ? { hostKind: decision.hostKind } : {}),
98
+ ...(decision.basis !== undefined ? { basis: decision.basis } : {}),
99
+ budgetMinChars: PROMPT_BUDGET_MIN_CHARS,
100
+ budgetMaxChars: PROMPT_BUDGET_MAX_CHARS,
101
+ costChars,
102
+ budgetScope,
103
+ unit: PROMPT_INJECTION_UNIT,
104
+ };
105
+ // SPEC §6.3: approval time holds no runtime model identity — classify
106
+ // against the SUPPORTED budget range instead of pretending to know the
107
+ // executing model's window.
108
+ const tierFit = classifySingleItemBudgetTier(costChars);
109
+ if (tierFit === 'fits_all_tiers') {
90
110
  return {
91
111
  status: 'deliverable',
92
- route: decision.route,
93
- ...(decision.hostKind !== undefined ? { hostKind: decision.hostKind } : {}),
94
- ...(decision.basis !== undefined ? { basis: decision.basis } : {}),
95
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
96
- costChars,
97
- budgetScope,
98
- unit: PROMPT_INJECTION_UNIT,
112
+ ...shared,
113
+ budget: PROMPT_BUDGET_MIN_CHARS,
114
+ };
115
+ }
116
+ if (tierFit === 'model_dependent') {
117
+ return {
118
+ status: 'model_dependent',
119
+ ...shared,
120
+ nextAction: MODEL_DEPENDENT_NEXT_ACTION,
99
121
  };
100
122
  }
101
123
  return {
102
124
  status: 'undeliverable',
125
+ ...shared,
103
126
  reason: 'single_item_exceeds_budget',
104
- route: decision.route,
105
- ...(decision.hostKind !== undefined ? { hostKind: decision.hostKind } : {}),
106
- ...(decision.basis !== undefined ? { basis: decision.basis } : {}),
107
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
108
- costChars,
109
- overByChars: costChars - RUNTIME_V2_PRINCIPLE_BUDGET,
110
- budgetScope,
111
- unit: PROMPT_INJECTION_UNIT,
127
+ budget: PROMPT_BUDGET_MAX_CHARS,
128
+ overByChars: costChars - PROMPT_BUDGET_MAX_CHARS,
112
129
  nextAction: UNDELIVERABLE_NEXT_ACTION,
113
130
  };
114
131
  }
115
- // Route unconfirmed: report per-host fit for every standard host instead of
116
- // guessing (R-A1) — the write gate refuses, the UI can show both facts.
117
- // Roster derives from the single SSoT list (review P3-1: a hand-typed
118
- // two-host array dropped WorkBuddy from unconfirmed deliverability reports).
132
+ // Route unconfirmed: report per-host tier fit for every standard host
133
+ // instead of guessing (R-A1) — the write gate refuses, the UI can show
134
+ // both facts. Roster derives from the single SSoT list (review P3-1: a
135
+ // hand-typed two-host array dropped WorkBuddy from unconfirmed
136
+ // deliverability reports).
119
137
  const perHost = PROMPT_INJECTION_TARGET_HOSTS.map((hostKind) => {
120
138
  const route = promptRouteForHost(hostKind, decision.flagEnabled);
121
- const { fits, costChars } = computePromptSingleItemFit({
139
+ const { costChars } = computePromptSingleItemFit({
122
140
  principle: resolved.principle,
123
141
  route,
124
142
  selfReportEnabled,
125
143
  });
126
- return { hostKind, fits, costChars };
144
+ return { hostKind, tierFit: classifySingleItemBudgetTier(costChars), costChars };
127
145
  });
128
146
  return {
129
147
  status: 'unconfirmed',
@@ -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;