@principles/host-runtime 0.7.10 → 0.7.12

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 } from '@principles/core/runtime-v2';
1
+ import { type ActivatedPrinciple, type PromptSelectionPolicy } from '@principles/core/runtime-v2';
2
2
  export interface ActivePrinciplePromptResult {
3
3
  additionalContext: string;
4
4
  principleIds: string[];
@@ -11,6 +11,16 @@ export interface ActivePrinciplePromptResult {
11
11
  excludedCount: number;
12
12
  exclusionReason?: 'host_principle_overlap';
13
13
  allValidatedPrinciplesExcluded: boolean;
14
+ /** PRI-904: budget-packing policy that produced this selection. */
15
+ selectionPolicy?: PromptSelectionPolicy;
16
+ /** PRI-904: candidates that reached the budget selector. */
17
+ eligibleCount?: number;
18
+ /** PRI-904: circular scan start; present only under fair_rotation_v1. */
19
+ rotationStartIndex?: number;
20
+ /** PRI-904 (bounded, max 16): activation ids dropped for insufficient remaining budget. */
21
+ droppedActivationIds?: string[];
22
+ /** PRI-904 (bounded, max 16): activation ids that cannot fit even in an empty payload. */
23
+ oversizedActivationIds?: string[];
14
24
  }
15
25
  export interface PromptActivationCandidates {
16
26
  principles: ActivatedPrinciple[];
@@ -34,4 +44,10 @@ export declare function readPromptActivationCandidates(input: {
34
44
  export declare function buildActivePrinciplePromptContext(input: {
35
45
  workspaceDir: string;
36
46
  excludePrincipleIds?: ReadonlySet<string>;
47
+ /**
48
+ * PRI-904 fair-rotation round key (derive via roundKeyFromRunIdentity from
49
+ * the host run/turn id). Absent → legacy FIFO prefix policy (rollback /
50
+ * characterization baseline).
51
+ */
52
+ roundKey?: number;
37
53
  }): Promise<ActivePrinciplePromptResult>;
@@ -3,6 +3,8 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { escapeXml } from '@principles/core/prompt-builder';
5
5
  import { loadPdConfigForPlugin } from './pd-config.js';
6
+ /** PRI-904 SPEC §10: bounded diagnostic id lists (mirrors the core selector's cap of 16). */
7
+ const MAX_SHARED_DIAGNOSTIC_IDS = 16;
6
8
  /**
7
9
  * PR #1844 follow-up: the activation READ half of buildActivePrinciplePromptContext,
8
10
  * exported so the console budget projection can share the exact same input
@@ -85,17 +87,49 @@ export async function buildActivePrinciplePromptContext(input) {
85
87
  additionalContext: '', principleIds: [], activationIds: [], artifactIds: [], warnings,
86
88
  budget: RUNTIME_V2_PRINCIPLE_BUDGET, truncated: false, excludedPrincipleIds,
87
89
  excludedCount: excludedPrincipleIds.length, allValidatedPrinciplesExcluded: false,
90
+ selectionPolicy: 'legacy_fifo_prefix_v1', eligibleCount: 0,
88
91
  };
89
92
  }
90
93
  const included = [];
91
94
  let additionalContext = '';
92
95
  let truncated = false;
93
- for (const principle of principles) {
96
+ // PRI-904: same fair-rotation semantics as the plugin route's trimToBudget —
97
+ // deterministic rotating start over the base (ASC) candidate order, circular
98
+ // scan, continue-on-non-fit. Costs are measured with the REAL serializer
99
+ // (renderPrinciplesToDirectives) so budget accounting stays exact here too.
100
+ const { roundKey } = input;
101
+ const selectionPolicy = roundKey === undefined || principles.length === 0 ? 'legacy_fifo_prefix_v1' : 'fair_rotation_v1';
102
+ const n = principles.length;
103
+ // Guard on the policy, not just the key: an all-excluded candidate list has
104
+ // n === 0 and must not emit a NaN start (core trimToBudget omits the field
105
+ // for empty input — parity requires the same here).
106
+ const rotationStartIndex = selectionPolicy === 'fair_rotation_v1' && roundKey !== undefined
107
+ ? ((roundKey % n) + n) % n
108
+ : undefined;
109
+ const droppedActivationIds = [];
110
+ const oversizedActivationIds = [];
111
+ const standaloneFits = (principle) => renderPrinciplesToDirectives([principle], new Set([principle.principleId]), { escapeFn: escapeXml, selfReportInstruction: selfReportEnabled }).length
112
+ <= RUNTIME_V2_PRINCIPLE_BUDGET;
113
+ const scanOrder = rotationStartIndex === undefined
114
+ ? principles
115
+ : principles.slice(rotationStartIndex).concat(principles.slice(0, rotationStartIndex));
116
+ for (const principle of scanOrder) {
94
117
  const candidate = [...included, principle];
95
118
  const candidateContext = renderPrinciplesToDirectives(candidate, new Set(candidate.map((entry) => entry.principleId)), { escapeFn: escapeXml, selfReportInstruction: selfReportEnabled });
96
119
  if (candidateContext.length > RUNTIME_V2_PRINCIPLE_BUDGET) {
97
- truncated = true;
98
- break;
120
+ if (standaloneFits(principle)) {
121
+ // Fits alone but not now — normal budget truncation.
122
+ truncated = true;
123
+ if (droppedActivationIds.length < MAX_SHARED_DIAGNOSTIC_IDS)
124
+ droppedActivationIds.push(principle.activationId);
125
+ }
126
+ else if (oversizedActivationIds.length < MAX_SHARED_DIAGNOSTIC_IDS) {
127
+ // Can never fit alone — oversize, not starvation (PRI-904 SPEC §6.6).
128
+ oversizedActivationIds.push(principle.activationId);
129
+ }
130
+ if (selectionPolicy === 'legacy_fifo_prefix_v1')
131
+ break;
132
+ continue;
99
133
  }
100
134
  included.push(principle);
101
135
  additionalContext = candidateContext;
@@ -116,5 +150,10 @@ export async function buildActivePrinciplePromptContext(input) {
116
150
  excludedCount: excludedPrincipleIds.length,
117
151
  allValidatedPrinciplesExcluded: excludedPrincipleIds.length > 0 && principles.length === 0,
118
152
  ...(excludedPrincipleIds.length > 0 ? { exclusionReason: 'host_principle_overlap' } : {}),
153
+ selectionPolicy,
154
+ eligibleCount: principles.length,
155
+ ...(rotationStartIndex !== undefined ? { rotationStartIndex } : {}),
156
+ ...(droppedActivationIds.length > 0 ? { droppedActivationIds } : {}),
157
+ ...(oversizedActivationIds.length > 0 ? { oversizedActivationIds } : {}),
119
158
  };
120
159
  }
package/dist/index.js CHANGED
@@ -177,6 +177,16 @@ export function createProductionHostRuntime(options = {}) {
177
177
  }),
178
178
  beforeToolCall: options.beforeToolCall ?? productionGate,
179
179
  async beforePromptBuild(event) {
180
+ // PRI-904 Phase-1 (Option 3): this production path is ALSO the Codex
181
+ // route, and Codex never writes trajectory.db::user_turns — its
182
+ // ingestion writes governance_* rows instead. Reading a session turn
183
+ // ordinal here would therefore be a false round authority: on Codex it
184
+ // either throws (→ legacy) or returns a constant 1 (→ fixed start, a
185
+ // different positional starvation while the event still claimed a fair
186
+ // rotation). So the shared production path passes NO round key and
187
+ // reports `legacy_fifo_prefix_v1` honestly. The selector's fair-rotation
188
+ // capability is retained and stays reachable for callers that DO hold a
189
+ // legitimate advancing round authority.
180
190
  const prompt = await buildActivePrinciplePromptContext({
181
191
  workspaceDir: event.context.workspaceDir,
182
192
  excludePrincipleIds: options.promptExcludePrincipleIds?.(event),
@@ -203,6 +213,14 @@ export function createProductionHostRuntime(options = {}) {
203
213
  budget: RUNTIME_V2_PRINCIPLE_BUDGET,
204
214
  ...(prompt.truncated !== undefined ? { v2Truncated: prompt.truncated } : {}),
205
215
  ...(event.context.turnId !== undefined ? { runId: event.context.turnId } : {}),
216
+ // PRI-904: selection diagnostics (bounded, optional). On this route
217
+ // the policy is legacy_fifo_prefix_v1 and NO rotation provenance is
218
+ // emitted — a fair claim would be untrue here (see above).
219
+ ...(prompt.selectionPolicy !== undefined ? { selectionPolicy: prompt.selectionPolicy } : {}),
220
+ ...(prompt.eligibleCount !== undefined ? { eligibleCount: prompt.eligibleCount } : {}),
221
+ ...(prompt.rotationStartIndex !== undefined ? { rotationStartIndex: prompt.rotationStartIndex } : {}),
222
+ ...(prompt.droppedActivationIds !== undefined && prompt.droppedActivationIds.length > 0 ? { droppedActivationIds: prompt.droppedActivationIds } : {}),
223
+ ...(prompt.oversizedActivationIds !== undefined && prompt.oversizedActivationIds.length > 0 ? { oversizedActivationIds: prompt.oversizedActivationIds } : {}),
206
224
  });
207
225
  }
208
226
  catch (err) {
@@ -1,3 +1,4 @@
1
+ import { type PromptSelectionPolicy } from '@principles/core/runtime-v2';
1
2
  export interface PromptInjectionProjection {
2
3
  /** Which real injection route the workspace is on (abstraction_layer_v1 flag). */
3
4
  route: 'legacy_trim' | 'shared_render';
@@ -13,6 +14,38 @@ export interface PromptInjectionProjection {
13
14
  injectedPrincipleIds: string[];
14
15
  injectedActivationIds: string[];
15
16
  warnings: string[];
17
+ /**
18
+ * PRI-935: the budget-packing policy that produced THIS projection
19
+ * (`legacy_fifo_prefix_v1` | `fair_rotation_v1`). The console must not
20
+ * describe a fair-rotation workspace in FIFO terms, so the policy travels
21
+ * with the projection instead of being re-derived by each consumer.
22
+ */
23
+ selectionPolicy: PromptSelectionPolicy;
24
+ /**
25
+ * PRI-935: activations that the production route will inject under SOME
26
+ * round key, i.e. activations that are merely rotated out of the current
27
+ * window rather than structurally starved.
28
+ *
29
+ * Reachability is a property of the SELECTION POLICY, not of any single
30
+ * round: a fair-rotation workspace reaches every non-oversized eligible
31
+ * entry within N turns. So this is computed under the production policy
32
+ * (fair rotation whenever the route actually rotates) and is therefore
33
+ * non-empty even for a caller that supplied no round key — which is
34
+ * exactly the console's situation. Empty only when the production route
35
+ * genuinely does not rotate (shared route) or nothing is eligible.
36
+ */
37
+ eventuallyInjectedActivationIds: string[];
38
+ /** Number of eligible candidates that reached the selector. */
39
+ eligibleCount: number;
40
+ /**
41
+ * PRI-935: whether the PRODUCTION route for this workspace rotates.
42
+ * The forecast's own `selectionPolicy` answers "which policy did THIS
43
+ * projection run", which is legacy for a console that holds no session
44
+ * round key; this answers "will the agent's real injection rotate",
45
+ * which is what determines whether an out-of-window activation is queued
46
+ * or starved. They differ exactly in the case this whole fix is about.
47
+ */
48
+ productionRotates: boolean;
16
49
  }
17
50
  /**
18
51
  * PR #1844 follow-up: forecast the prompt-injection budget from the SAME
@@ -31,8 +64,25 @@ export interface PromptInjectionProjection {
31
64
  * candidates against the legacy evolution ledger before trimming; the
32
65
  * console cannot replay that reducer read-only, so this projection may
33
66
  * forecast slightly MORE consumption than the real injection (never less).
67
+ *
68
+ * PRI-935 — round-key alignment. The plugin derives its fair-rotation round
69
+ * key from the CURRENT session's turn ordinal
70
+ * (`nextSessionTurnOrdinal`), which the console has no access to. Passing no
71
+ * key therefore does NOT make the forecast "more conservative": it selects a
72
+ * DIFFERENT policy (`legacy_fifo_prefix_v1`) than the one production runs, and
73
+ * legacy FIFO structurally excludes the newest activation from every
74
+ * truncated selection. The console must either supply the round key or
75
+ * report the policy honestly, so this function takes the key as an explicit
76
+ * input and exposes `selectionPolicy` on the result.
34
77
  */
35
78
  export declare function buildLivePromptInjectionProjection(input: {
36
79
  workspaceDir: string;
37
80
  excludePrincipleIds?: ReadonlySet<string>;
81
+ /**
82
+ * PRI-935: the fair-rotation round key the production route is using this
83
+ * turn. Callers that hold one (the plugin) pass it; callers that do not
84
+ * (the console) omit it and MUST read `selectionPolicy` off the result
85
+ * rather than assuming FIFO.
86
+ */
87
+ roundKey?: number;
38
88
  }): Promise<PromptInjectionProjection>;
@@ -2,6 +2,28 @@ import { RUNTIME_V2_PRINCIPLE_BUDGET, computeFeatureFlagsFromConfig, trimToBudge
2
2
  import { escapeXml } from '@principles/core/prompt-builder';
3
3
  import { loadPdConfigForPlugin } from './pd-config.js';
4
4
  import { buildActivePrinciplePromptContext, readPromptActivationCandidates } from './active-principle-prompt.js';
5
+ /**
6
+ * PRI-935: which activations a fair-rotation selector injects across the
7
+ * whole ring. The plugin's round key advances by one per recorded user turn
8
+ * within a continuously advancing session, so N consecutive turns cover all
9
+ * N ring positions: an activation absent from the current window but present
10
+ * in ANY round is reachable, not starved.
11
+ *
12
+ * Kept here (not in the console) so the production selector's own arithmetic
13
+ * stays the single authority for what "reachable" means.
14
+ */
15
+ function collectReachableActivationIds(principles, budget) {
16
+ const reachable = new Set();
17
+ for (let roundKey = 0; roundKey < principles.length; roundKey += 1) {
18
+ const result = trimToBudget(principles, budget, escapeXml, roundKey);
19
+ for (const principleId of result.injectedIds) {
20
+ const match = principles.find((p) => p.principleId === principleId);
21
+ if (match)
22
+ reachable.add(match.activationId);
23
+ }
24
+ }
25
+ return [...reachable];
26
+ }
5
27
  /**
6
28
  * PR #1844 follow-up: forecast the prompt-injection budget from the SAME
7
29
  * projection the agent injection itself uses, routed by the same
@@ -19,6 +41,16 @@ import { buildActivePrinciplePromptContext, readPromptActivationCandidates } fro
19
41
  * candidates against the legacy evolution ledger before trimming; the
20
42
  * console cannot replay that reducer read-only, so this projection may
21
43
  * forecast slightly MORE consumption than the real injection (never less).
44
+ *
45
+ * PRI-935 — round-key alignment. The plugin derives its fair-rotation round
46
+ * key from the CURRENT session's turn ordinal
47
+ * (`nextSessionTurnOrdinal`), which the console has no access to. Passing no
48
+ * key therefore does NOT make the forecast "more conservative": it selects a
49
+ * DIFFERENT policy (`legacy_fifo_prefix_v1`) than the one production runs, and
50
+ * legacy FIFO structurally excludes the newest activation from every
51
+ * truncated selection. The console must either supply the round key or
52
+ * report the policy honestly, so this function takes the key as an explicit
53
+ * input and exposes `selectionPolicy` on the result.
22
54
  */
23
55
  export async function buildLivePromptInjectionProjection(input) {
24
56
  const sharedRoute = computeFeatureFlagsFromConfig(loadPdConfigForPlugin(input.workspaceDir).effective).flags.abstraction_layer_v1?.enabled === true;
@@ -32,6 +64,13 @@ export async function buildLivePromptInjectionProjection(input) {
32
64
  injectedPrincipleIds: context.principleIds,
33
65
  injectedActivationIds: context.activationIds,
34
66
  warnings: context.warnings,
67
+ // The shared production path deliberately passes no round key
68
+ // (host-runtime/src/index.ts), so this route genuinely does not rotate
69
+ // and claims nothing about eventual reachability.
70
+ selectionPolicy: context.selectionPolicy ?? 'legacy_fifo_prefix_v1',
71
+ eventuallyInjectedActivationIds: [],
72
+ eligibleCount: context.eligibleCount ?? 0,
73
+ productionRotates: false,
35
74
  };
36
75
  }
37
76
  const candidates = await readPromptActivationCandidates(input);
@@ -45,10 +84,23 @@ export async function buildLivePromptInjectionProjection(input) {
45
84
  injectedPrincipleIds: [],
46
85
  injectedActivationIds: [],
47
86
  warnings,
87
+ selectionPolicy: 'legacy_fifo_prefix_v1',
88
+ eventuallyInjectedActivationIds: [],
89
+ eligibleCount: 0,
90
+ productionRotates: false,
48
91
  };
49
92
  }
50
- const trimmed = trimToBudget(principles, RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml);
93
+ // PRI-935: pass the caller's round key so the forecast runs the SAME
94
+ // packing policy production runs this turn.
95
+ const trimmed = trimToBudget(principles, RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml, input.roundKey);
51
96
  const injected = principles.filter((p) => trimmed.injectedIds.has(p.principleId));
97
+ // PRI-935: the legacy_trim route IS the OpenClaw plugin-local route, which
98
+ // rotates on every recorded user turn (PRI-904). Reachability is therefore
99
+ // computed under fair rotation regardless of whether THIS forecast holds a
100
+ // round key — a console without a session key must still be able to tell
101
+ // "queued behind rotation" apart from "structurally starved", otherwise it
102
+ // keeps telling the Owner to deactivate healthy principles.
103
+ const eventuallyInjectedActivationIds = collectReachableActivationIds(principles, RUNTIME_V2_PRINCIPLE_BUDGET);
52
104
  return {
53
105
  route: 'legacy_trim',
54
106
  budget: RUNTIME_V2_PRINCIPLE_BUDGET,
@@ -57,5 +109,9 @@ export async function buildLivePromptInjectionProjection(input) {
57
109
  injectedPrincipleIds: [...trimmed.injectedIds],
58
110
  injectedActivationIds: injected.map((p) => p.activationId),
59
111
  warnings,
112
+ selectionPolicy: trimmed.selectionPolicy,
113
+ eventuallyInjectedActivationIds,
114
+ eligibleCount: trimmed.eligibleCount,
115
+ productionRotates: true,
60
116
  };
61
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "lint": "eslint \"src/**/*.ts\""
22
22
  },
23
23
  "dependencies": {
24
- "@principles/core": "^1.287.5",
24
+ "@principles/core": "^1.287.7",
25
25
  "@principles/install-layout": "^0.2.7",
26
26
  "better-sqlite3": "^13.0.3",
27
27
  "js-yaml": "^5.4.1"