@principles/host-runtime 0.7.14 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RUNTIME_V2_PRINCIPLE_BUDGET, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, listEntryLine, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, trimToBudget, } from '@principles/core/runtime-v2';
4
+ import { escapeXml } from '@principles/core/prompt-builder';
5
+ import { loadPdConfigForPlugin } from './pd-config.js';
6
+ import { PROMPT_INJECTION_UNIT, promptRouteForHost, resolvePromptInjectionRouteDecision, } from './prompt-injection-projection.js';
7
+ function syntheticPendingActivation(artifactId) {
8
+ return {
9
+ activationId: `pending:${artifactId}`,
10
+ idempotencyKey: `pending:${artifactId}::prompt`,
11
+ artifactId,
12
+ channel: 'prompt',
13
+ action: 'prompt_activate',
14
+ targetRef: `pending://${artifactId}`,
15
+ activatedAt: new Date(0).toISOString(),
16
+ deactivatedAt: null,
17
+ };
18
+ }
19
+ /** Resolve the REAL principle identity + text from the artifact content. */
20
+ export function resolvePromptCandidatePrinciple(artifact) {
21
+ return resolvePrincipleFromArtifact({
22
+ artifact_id: artifact.artifactId,
23
+ artifact_kind: artifact.artifactKind,
24
+ content_json: artifact.contentJson,
25
+ validation_status: artifact.validationStatus,
26
+ }, syntheticPendingActivation(artifact.artifactId));
27
+ }
28
+ function selfReportEnabledFor(workspaceDir) {
29
+ return computeFeatureFlagsFromConfig(loadPdConfigForPlugin(workspaceDir).effective).flags.principle_receipt_self_report?.enabled === true;
30
+ }
31
+ /**
32
+ * Positive single-item evidence under a given route, using the production
33
+ * selector itself (AC-06: a row is "fits" because its own serialized entry
34
+ * fits — never because it is absent from a bounded diagnostic list).
35
+ *
36
+ * `costChars` is what the single-entry selection would consume, measured the
37
+ * same way the route's projection reports `usedChars` (the joined selected
38
+ * lines). For an entry that does NOT fit, trimToBudget returns only the
39
+ * header line, so the would-be cost is reconstructed from the header line it
40
+ * DID return plus the real entry line (same format authority, listEntryLine).
41
+ */
42
+ export function computePromptSingleItemFit(input) {
43
+ if (input.route === 'shared_render') {
44
+ 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 };
46
+ }
47
+ const trimmed = trimToBudget([input.principle], RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml);
48
+ const fits = trimmed.injectedIds.has(input.principle.principleId);
49
+ const costChars = trimmed.lines.length > 1
50
+ ? trimmed.lines.join('\n').length
51
+ : (trimmed.lines[0]?.length ?? 0) + 1 + listEntryLine(input.principle, escapeXml).length;
52
+ return { fits, costChars };
53
+ }
54
+ 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)';
55
+ /**
56
+ * Precheck one artifact against the workspace's effective injection route.
57
+ * Pure read — no state mutation, safe to call before any governance write.
58
+ */
59
+ export async function checkPromptArtifactDeliverability(input) {
60
+ if (!input.artifact) {
61
+ return {
62
+ status: 'unconfirmed',
63
+ reason: 'artifact_unavailable: artifact could not be read from the store',
64
+ nextAction: 'check pi_artifacts for the approval artifact, then retry',
65
+ };
66
+ }
67
+ const resolved = resolvePromptCandidatePrinciple(input.artifact);
68
+ if (!resolved.ok) {
69
+ return {
70
+ status: 'unconfirmed',
71
+ reason: `artifact_content_unresolved: ${resolved.warning}`,
72
+ nextAction: 'fix the artifact content (kind/validation/principleId/statement), then retry',
73
+ };
74
+ }
75
+ const selfReportEnabled = selfReportEnabledFor(input.workspaceDir);
76
+ const decision = resolvePromptInjectionRouteDecision({
77
+ workspaceDir: input.workspaceDir,
78
+ targetHost: input.targetHost,
79
+ });
80
+ if (decision.status === 'confirmed' && decision.route !== undefined) {
81
+ const { fits, costChars } = computePromptSingleItemFit({
82
+ principle: resolved.principle,
83
+ route: decision.route,
84
+ selfReportEnabled,
85
+ });
86
+ const budgetScope = decision.route === 'shared_render'
87
+ ? 'full_directive_context'
88
+ : 'selected_lines';
89
+ if (fits) {
90
+ return {
91
+ 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,
99
+ };
100
+ }
101
+ return {
102
+ status: 'undeliverable',
103
+ 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,
112
+ nextAction: UNDELIVERABLE_NEXT_ACTION,
113
+ };
114
+ }
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
+ const hosts = ['openclaw', 'codex'];
118
+ const perHost = hosts.map((hostKind) => {
119
+ const route = promptRouteForHost(hostKind, decision.flagEnabled);
120
+ const { fits, costChars } = computePromptSingleItemFit({
121
+ principle: resolved.principle,
122
+ route,
123
+ selfReportEnabled,
124
+ });
125
+ return { hostKind, fits, costChars };
126
+ });
127
+ return {
128
+ status: 'unconfirmed',
129
+ reason: `route_unconfirmed: ${decision.unconfirmedReason ?? 'effective injection route cannot be confirmed'}`,
130
+ nextAction: decision.nextAction ?? 'pass an explicit target host (openclaw|codex)',
131
+ perHost,
132
+ };
133
+ }
134
+ /** Load one artifact read-only from `<workspace>/.pd/state.db`. Failures carry a reason (rc-9). */
135
+ export async function loadPromptArtifactById(workspaceDir, artifactId) {
136
+ const stateDbPath = path.join(workspaceDir, '.pd', 'state.db');
137
+ if (!fs.existsSync(stateDbPath)) {
138
+ return { ok: false, reason: `activation_db_not_found: ${stateDbPath}; nextAction=initialize_workspace_runtime_state` };
139
+ }
140
+ let connection;
141
+ try {
142
+ connection = new SqliteConnection({ workspaceDir, readonly: true, bootstrapIfMissing: false });
143
+ const record = await new SqlitePIArtifactStore(connection).getArtifactById(artifactId);
144
+ if (record) {
145
+ return {
146
+ ok: true,
147
+ artifact: {
148
+ artifactId: record.artifactId,
149
+ artifactKind: record.artifactKind,
150
+ contentJson: record.contentJson,
151
+ validationStatus: record.validationStatus,
152
+ },
153
+ };
154
+ }
155
+ return { ok: false, reason: `artifact_not_found: artifactId=${artifactId}; nextAction=check_pi_artifacts_table` };
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ return { ok: false, reason: `artifact_read_failed: ${message}; nextAction=check_workspace_pd_state_db` };
160
+ }
161
+ finally {
162
+ connection?.close();
163
+ }
164
+ }
165
+ /** Combined load + precheck for write gates that only hold an artifact id (CLI approve). */
166
+ export async function checkPromptArtifactDeliverabilityById(input) {
167
+ const loaded = await loadPromptArtifactById(input.workspaceDir, input.artifactId);
168
+ if (!loaded.ok) {
169
+ return {
170
+ status: 'unconfirmed',
171
+ reason: loaded.reason,
172
+ nextAction: 'fix the artifact store readability, then retry the approval',
173
+ };
174
+ }
175
+ return checkPromptArtifactDeliverability({ ...input, artifact: loaded.artifact });
176
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * PD_PROMPT_CAPACITY_V1 Phase C / AC-12: read-model over the EXISTING
3
+ * injection-event JSONL (`<stateDir>/logs/events_<date>.jsonl`, written by the
4
+ * OpenClaw EventLog and the Codex shared path via appendEventLogLine).
5
+ *
6
+ * Honesty rules this model enforces (SPEC §6):
7
+ * - NO logs ≠ zero injections: an activation with no events is simply absent
8
+ * from `rows`; the caller reports "unknown", never "0 proven injections".
9
+ * - Duplicate lines (identical serialized payloads — rollover/replay) count
10
+ * once; the dedupe count is reported, not hidden.
11
+ * - Statistics never mix entities: rows are keyed by (workspaceDir,
12
+ * activationId); the same runId across different workspaces/sessions stays
13
+ * separate.
14
+ * - Old events missing identity fields (runId) still count as injection FACTS
15
+ * but flag `runIdComplete` false — the numbers degrade to "known counts,
16
+ * incomplete binding", never to zero.
17
+ * - Model self-report (principle_applications kind self_reported) is a
18
+ * separate signal and is deliberately NOT read here: self-report ≠
19
+ * compliance.
20
+ *
21
+ * Untrusted input discipline: every line is parsed as `unknown` and narrowed
22
+ * with typeof guards (rc-1..rc-5); structurally invalid lines are counted in
23
+ * `parseFailures` and skipped loudly, never guessed (rc-3).
24
+ */
25
+ export interface InjectionEvidenceRow {
26
+ workspaceDir: string;
27
+ activationId: string;
28
+ hostKind: 'openclaw' | 'codex' | 'unknown';
29
+ hostIdentityComplete: boolean;
30
+ eventIdentityComplete: boolean;
31
+ /** Deduplicated injection facts: this activation appeared in the injected set. */
32
+ provenInjections: number;
33
+ /** Raw matching events before dedupe. */
34
+ rawEventCount: number;
35
+ duplicateEventsDeduped: number;
36
+ sessions: string[];
37
+ runIds: string[];
38
+ /** False when any counted event lacked runId (old events / hosts without turn ids). */
39
+ runIdComplete: boolean;
40
+ lastEventAt: string | null;
41
+ }
42
+ export interface InjectionEventEvidenceReport {
43
+ filesRead: string[];
44
+ lines: number;
45
+ parseFailures: number;
46
+ rows: InjectionEvidenceRow[];
47
+ note: string;
48
+ }
49
+ export declare function readInjectionEventEvidence(input: {
50
+ stateDir: string;
51
+ }): InjectionEventEvidenceReport;
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function nonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
8
+ }
9
+ export function readInjectionEventEvidence(input) {
10
+ const logsRoot = path.resolve(input.stateDir, 'logs');
11
+ const filesRead = [];
12
+ if (!fs.existsSync(logsRoot)) {
13
+ return {
14
+ filesRead,
15
+ lines: 0,
16
+ parseFailures: 0,
17
+ rows: [],
18
+ note: 'no_logs: the injection-event log directory does not exist — injection/coverage numbers are UNKNOWN, not zero',
19
+ };
20
+ }
21
+ const files = fs.readdirSync(logsRoot)
22
+ .filter((name) => name.startsWith('events_') && name.endsWith('.jsonl'))
23
+ .sort();
24
+ let lines = 0;
25
+ let parseFailures = 0;
26
+ // Rows are separated by workspace, host, and activation. Old hostless events
27
+ // remain in an explicit unknown bucket and cannot merge with either host.
28
+ const rows = new Map();
29
+ for (const name of files) {
30
+ // Containment guard: only bare event filenames inside the resolved logs
31
+ // root are ever read — a crafted name cannot escape the directory.
32
+ const filePath = path.resolve(logsRoot, name);
33
+ if (!filePath.startsWith(logsRoot + path.sep) || path.basename(name) !== name)
34
+ continue;
35
+ filesRead.push(filePath);
36
+ const raw = fs.readFileSync(filePath, 'utf8');
37
+ for (const line of raw.split('\n')) {
38
+ if (line.trim().length === 0)
39
+ continue;
40
+ lines += 1;
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(line);
44
+ }
45
+ catch {
46
+ parseFailures += 1;
47
+ continue;
48
+ }
49
+ if (!isRecord(parsed)) {
50
+ parseFailures += 1;
51
+ continue;
52
+ }
53
+ if (nonEmptyString(parsed.type) !== 'runtime_v2_prompt_activations_injected')
54
+ continue;
55
+ const ts = nonEmptyString(parsed.ts) ?? null;
56
+ const { data } = parsed;
57
+ if (!isRecord(data)) {
58
+ parseFailures += 1;
59
+ continue;
60
+ }
61
+ const workspaceDir = nonEmptyString(data.workspaceDir);
62
+ const sessionId = nonEmptyString(data.sessionId) ?? nonEmptyString(parsed.sessionId);
63
+ const runId = nonEmptyString(data.runId);
64
+ const eventId = nonEmptyString(data.eventId);
65
+ const rawHostKind = data.hostKind;
66
+ const hostKind = rawHostKind === 'openclaw' || rawHostKind === 'codex' ? rawHostKind : 'unknown';
67
+ const { activationIds } = data;
68
+ if (workspaceDir === undefined || sessionId === undefined || !Array.isArray(activationIds)
69
+ || !activationIds.every((id) => typeof id === 'string')) {
70
+ // rc-3/rc-4: identity binding incomplete → loud skip, never a guess.
71
+ parseFailures += 1;
72
+ continue;
73
+ }
74
+ const dedupeKey = eventId !== undefined
75
+ ? JSON.stringify(['event', hostKind, eventId])
76
+ : JSON.stringify(['legacy', hostKind, workspaceDir, sessionId, runId ?? null, ts, activationIds.join(','), data.injectedCharCount, data.selectionPolicy ?? null, data.rotationStartIndex ?? null]);
77
+ for (const activationId of activationIds) {
78
+ const rowKey = JSON.stringify([workspaceDir, hostKind, activationId]);
79
+ let row = rows.get(rowKey);
80
+ if (row === undefined) {
81
+ row = {
82
+ workspaceDir,
83
+ activationId,
84
+ hostKind,
85
+ hostIdentityComplete: hostKind !== 'unknown',
86
+ eventIdentityComplete: true,
87
+ provenInjections: 0,
88
+ rawEventCount: 0,
89
+ duplicateEventsDeduped: 0,
90
+ sessions: [],
91
+ runIds: [],
92
+ runIdComplete: true,
93
+ lastEventAt: null,
94
+ seen: new Set(),
95
+ };
96
+ rows.set(rowKey, row);
97
+ }
98
+ row.rawEventCount += 1;
99
+ if (hostKind === 'unknown')
100
+ row.hostIdentityComplete = false;
101
+ if (eventId === undefined)
102
+ row.eventIdentityComplete = false;
103
+ if (!row.seen.has(dedupeKey)) {
104
+ row.seen.add(dedupeKey);
105
+ row.provenInjections += 1;
106
+ if (!row.sessions.includes(sessionId))
107
+ row.sessions.push(sessionId);
108
+ if (runId !== undefined && !row.runIds.includes(runId))
109
+ row.runIds.push(runId);
110
+ if (ts !== null && (row.lastEventAt === null || ts > row.lastEventAt))
111
+ row.lastEventAt = ts;
112
+ }
113
+ else {
114
+ row.duplicateEventsDeduped += 1;
115
+ }
116
+ if (runId === undefined)
117
+ row.runIdComplete = false;
118
+ }
119
+ }
120
+ }
121
+ return {
122
+ filesRead,
123
+ lines,
124
+ parseFailures,
125
+ rows: [...rows.values()].map((row) => ({
126
+ workspaceDir: row.workspaceDir,
127
+ activationId: row.activationId,
128
+ hostKind: row.hostKind,
129
+ hostIdentityComplete: row.hostIdentityComplete,
130
+ eventIdentityComplete: row.eventIdentityComplete,
131
+ provenInjections: row.provenInjections,
132
+ rawEventCount: row.rawEventCount,
133
+ duplicateEventsDeduped: row.duplicateEventsDeduped,
134
+ sessions: row.sessions,
135
+ runIds: row.runIds,
136
+ runIdComplete: row.runIdComplete,
137
+ lastEventAt: row.lastEventAt,
138
+ })),
139
+ note: 'absence from rows means UNKNOWN (no logs), never zero; self-report is a separate signal and is not compliance evidence; rows never merge workspaces',
140
+ };
141
+ }
@@ -1,7 +1,69 @@
1
1
  import { type PromptSelectionPolicy } from '@principles/core/runtime-v2';
2
+ /** Hosts with a production prompt-injection path today (shared GovernanceHostKind boundary). */
3
+ export type PromptInjectionTargetHost = 'openclaw' | 'codex';
4
+ export declare const PROMPT_INJECTION_TARGET_HOSTS: readonly PromptInjectionTargetHost[];
5
+ export type PromptInjectionRouteName = 'legacy_trim' | 'shared_render';
6
+ /**
7
+ * Which serialization the budget is charged against (SPEC R-A2): the legacy
8
+ * list route bills only the selected `- [id] text` lines (the directive
9
+ * wrapper and the self-report footer live OUTSIDE that budget on that route),
10
+ * while the shared route bills the full rendered directive context including
11
+ * wrapper and footer. Both are UTF-16 code units (`String.prototype.length`).
12
+ */
13
+ export type PromptInjectionBudgetScope = 'selected_lines' | 'full_directive_context';
14
+ export declare const PROMPT_INJECTION_UNIT: 'utf16_code_units';
15
+ /**
16
+ * AC-01: the effective injection route is a fact about WHICH HOST will run
17
+ * the prompt build, not about the workspace flag alone. Codex always injects
18
+ * through the shared route (`codex-adapter/src/pd-hook.ts`); OpenClaw routes
19
+ * by its `abstraction_layer_v1` gate (`openclaw-plugin/src/index.ts`). Host
20
+ * facts come from the existing durable provenance (declarations ∪ pain
21
+ * evidence — `readWorkspaceHostKindFacts`); an explicit request-level target
22
+ * overrides them. When the applicable hosts disagree (flag OFF + both hosts
23
+ * possible, or no host facts at all) the decision is `unconfirmed` and every
24
+ * consumer must say so instead of guessing FIFO or rotation (SPEC R-A1).
25
+ */
26
+ export interface PromptInjectionRouteDecision {
27
+ status: 'confirmed' | 'unconfirmed';
28
+ /** Present when status === 'confirmed' AND a single host is bound. */
29
+ hostKind?: PromptInjectionTargetHost;
30
+ /** Present when status === 'confirmed'. */
31
+ route?: PromptInjectionRouteName;
32
+ basis?: 'explicit_target' | 'workspace_host_facts' | 'flag_shared_all_hosts';
33
+ /** Present when explicit binding succeeds while workspace-wide host facts are unreadable. */
34
+ hostFactsStatus?: 'unreadable';
35
+ /** Raw `abstraction_layer_v1` flag state (context for the OpenClaw branch). */
36
+ flagEnabled: boolean;
37
+ declaredHostKinds: readonly string[];
38
+ effectiveHostKinds: readonly string[];
39
+ /** Present when status === 'unconfirmed'. */
40
+ unconfirmedReason?: string;
41
+ /** Present when status === 'unconfirmed'. */
42
+ nextAction?: string;
43
+ /** Route each standard host WOULD take (context for unconfirmed decisions). */
44
+ perHost?: readonly {
45
+ hostKind: PromptInjectionTargetHost;
46
+ route: PromptInjectionRouteName;
47
+ }[];
48
+ }
49
+ /**
50
+ * THE one route formula (P4): Codex always injects through the shared route
51
+ * (codex-adapter has no flag branch); OpenClaw routes by its
52
+ * `abstraction_layer_v1` gate. Every consumer that needs "which route would
53
+ * host X take" must call this — never re-type the ternary.
54
+ */
55
+ export declare function promptRouteForHost(hostKind: PromptInjectionTargetHost, flagEnabled: boolean): PromptInjectionRouteName;
56
+ /**
57
+ * Resolve the effective route for this workspace. Pure read of existing
58
+ * config + provenance facts — no new registry, no persisted state.
59
+ */
60
+ export declare function resolvePromptInjectionRouteDecision(input: {
61
+ workspaceDir: string;
62
+ targetHost?: PromptInjectionTargetHost;
63
+ }): PromptInjectionRouteDecision;
2
64
  export interface PromptInjectionProjection {
3
- /** Which real injection route the workspace is on (abstraction_layer_v1 flag). */
4
- route: 'legacy_trim' | 'shared_render';
65
+ /** Which real injection route THIS projection ran (explicit target or flag). */
66
+ route: PromptInjectionRouteName;
5
67
  budget: number;
6
68
  /**
7
69
  * Char count the REAL prompt path would consume: legacy route counts the
@@ -46,19 +108,48 @@ export interface PromptInjectionProjection {
46
108
  * or starved. They differ exactly in the case this whole fix is about.
47
109
  */
48
110
  productionRotates: boolean;
111
+ /** R-A2: the unit `usedChars`/`budget` are counted in. */
112
+ unit: typeof PROMPT_INJECTION_UNIT;
113
+ /** R-A2: which serialization the budget is charged against on this route. */
114
+ budgetScope: PromptInjectionBudgetScope;
115
+ /**
116
+ * R-A3: activation ids whose own entry cannot fit the budget even in an
117
+ * empty payload, independent of `truncated` (bounded diagnostic list —
118
+ * absence from a capped list is NOT evidence of fitting; see
119
+ * `oversizedDiagnosticTruncated`).
120
+ */
121
+ oversizedActivationIds: string[];
122
+ /** R-A3: activation ids dropped for insufficient remaining budget (bounded). */
123
+ droppedActivationIds: string[];
124
+ /** True when a bounded diagnostic list hit its cap — more ids may exist. */
125
+ oversizedDiagnosticTruncated: boolean;
126
+ droppedDiagnosticTruncated: boolean;
127
+ /**
128
+ * R-A2 (list route only): length of the FULL directive render of the
129
+ * injected set under the workspace self-report flag — the whole PD output
130
+ * block, NOT the host request and NOT tokens. Undefined on the shared
131
+ * route (where `usedChars` already IS the full render).
132
+ */
133
+ fullRenderChars?: number;
49
134
  }
50
135
  /**
51
136
  * PR #1844 follow-up: forecast the prompt-injection budget from the SAME
52
- * projection the agent injection itself uses, routed by the same
53
- * `abstraction_layer_v1` flag the OpenClaw plugin's runtime gate reads:
137
+ * projection the agent injection itself uses:
138
+ *
139
+ * - legacy route: activation candidates from `readPromptActivationCandidates`
140
+ * (same FIFO query + artifact resolve as the plugin's list path) trimmed by
141
+ * the REAL `trimToBudget` — identical input × ordering × trim logic as
142
+ * `openclaw-plugin/src/hooks/prompt.ts`;
143
+ * - shared route: delegate to `buildActivePrinciplePromptContext`, which IS
144
+ * the production shared path's projection source (Codex always; OpenClaw
145
+ * when its `abstraction_layer_v1` gate is on).
54
146
  *
55
- * - legacy route (live default, flag OFF): activation candidates from
56
- * `readPromptActivationCandidates` (same FIFO query + artifact resolve as
57
- * the plugin's `PromptActivationReader`) trimmed by the REAL
58
- * `trimToBudget` — identical input × ordering × trim logic as
59
- * `openclaw-plugin/src/hooks/prompt.ts`.
60
- * - shared route (flag ON): delegate to `buildActivePrinciplePromptContext`,
61
- * which IS the production shared path's projection source.
147
+ * Route selection here is EXPLICIT: `targetHost: 'codex'` forces the shared
148
+ * route (production truth for Codex), `targetHost: 'openclaw'` follows the
149
+ * workspace flag, and no target follows the flag (the historical behavior —
150
+ * callers that lack host context should prefer
151
+ * `resolveLivePromptInjectionProjection`, which binds the route to real host
152
+ * facts and refuses to guess).
62
153
  *
63
154
  * Known conservative gap: the plugin's legacy route additionally dedups
64
155
  * candidates against the legacy evolution ledger before trimming; the
@@ -66,14 +157,13 @@ export interface PromptInjectionProjection {
66
157
  * forecast slightly MORE consumption than the real injection (never less).
67
158
  *
68
159
  * PR-1894 — 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.
160
+ * key from the CURRENT session's turn ordinal (`nextSessionTurnOrdinal`),
161
+ * which the console has no access to. Passing no key therefore does NOT make
162
+ * the forecast "more conservative": it selects a DIFFERENT policy
163
+ * (`legacy_fifo_prefix_v1`) than the one production runs. The console must
164
+ * either supply the round key or report the policy honestly, so this
165
+ * function takes the key as an explicit input and exposes
166
+ * `selectionPolicy` on the result.
77
167
  */
78
168
  export declare function buildLivePromptInjectionProjection(input: {
79
169
  workspaceDir: string;
@@ -85,4 +175,30 @@ export declare function buildLivePromptInjectionProjection(input: {
85
175
  * rather than assuming FIFO.
86
176
  */
87
177
  roundKey?: number;
178
+ /** AC-01: explicit route binding — 'codex' always shared, 'openclaw' by flag. */
179
+ targetHost?: PromptInjectionTargetHost;
88
180
  }): Promise<PromptInjectionProjection>;
181
+ /**
182
+ * AC-01/R-A1: the host-aware entry every console/CLI consumer should use.
183
+ * Binds the route to real host facts (or an explicit request-level target)
184
+ * and returns either one confirmed projection or an honest `unconfirmed`
185
+ * result carrying per-host forecasts — never a guessed route.
186
+ */
187
+ export type LivePromptInjectionProjectionResolution = {
188
+ status: 'confirmed';
189
+ decision: PromptInjectionRouteDecision;
190
+ projection: PromptInjectionProjection;
191
+ } | {
192
+ status: 'unconfirmed';
193
+ decision: PromptInjectionRouteDecision;
194
+ perHost: readonly {
195
+ hostKind: PromptInjectionTargetHost;
196
+ projection: PromptInjectionProjection;
197
+ }[];
198
+ };
199
+ export declare function resolveLivePromptInjectionProjection(input: {
200
+ workspaceDir: string;
201
+ targetHost?: PromptInjectionTargetHost;
202
+ excludePrincipleIds?: ReadonlySet<string>;
203
+ roundKey?: number;
204
+ }): Promise<LivePromptInjectionProjectionResolution>;