@principles/host-runtime 0.10.3 → 0.11.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
  }
@@ -144,12 +144,23 @@ export type AdmitGovernanceSignalsResult = {
144
144
  ok: true;
145
145
  outcomes: readonly GovernanceAdmissionOutcome[];
146
146
  } | Degradation;
147
+ /** Public generic intake stays Codex + explicit WorkBuddy only. */
148
+ export declare function admitGovernanceSignals(input: AdmitGovernanceSignalsInput): AdmitGovernanceSignalsResult;
149
+ /** Pure check: no DB, no filesystem, no classifier, no historical transcript. */
150
+ export declare function assessWorkbuddyLiveKeyword(text: string, sessionId: string): {
151
+ matched: boolean;
152
+ reason: 'matched' | 'not_a_signal' | 'prompt_too_large';
153
+ };
147
154
  /**
148
- * Admit governance signal candidates. Each candidate commits in its own
149
- * trajectory.db transaction (marker + pain row + quota together), so one
150
- * failing candidate never rolls back another's admission.
155
+ * Narrow native event gateway: validate true current user origin and flags.
156
+ * The ordinary generic admission API cannot bypass this WorkBuddy gate.
151
157
  */
152
- export declare function admitGovernanceSignals(input: AdmitGovernanceSignalsInput): AdmitGovernanceSignalsResult;
158
+ export declare function admitTrustedWorkbuddyLiveCorrection(input: {
159
+ workspaceDir: string;
160
+ rawEvent: unknown;
161
+ now?: Date;
162
+ databaseFactory?: ObservationDatabaseFactory;
163
+ }): AdmitGovernanceSignalsResult;
153
164
  export interface EnsureGovernanceTaskInput {
154
165
  readonly workspaceDir: string;
155
166
  readonly logicalObservationKey: string;
@@ -34,7 +34,7 @@
34
34
  import fs from 'node:fs';
35
35
  import path from 'node:path';
36
36
  import Database from 'better-sqlite3';
37
- import { collectSync, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, disposePainSignalBridgesForWorkspace, sanitizeString, boundCorrectionEvidenceText, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
37
+ import { collectSync, computeFeatureFlagsFromConfig, CORRECTION_SEED_KEYWORDS, buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, PainToPrincipleService, PrincipleTreeLedgerAdapter, RuntimeStateManager, createDiagnosticianTaskId, disposePainSignalBridgesForWorkspace, sanitizeString, boundCorrectionEvidenceText, MAX_EVIDENCE_VALUE_CHARS, } from '@principles/core/runtime-v2';
38
38
  import { deriveProductionCorrectionPainIdentity, deriveProductionToolPainIdentity, hasProductionPainSchema, PRODUCTION_WRITE_TOOLS, } from './production-pain-evidence.js';
39
39
  import { promoteGovernanceEvidence, ensureGovernanceSchema } from './governance-observation-store.js';
40
40
  import { loadPdConfigForPlugin } from './pd-config.js';
@@ -432,7 +432,8 @@ function toolFailureTaskPayload(fields) {
432
432
  };
433
433
  return JSON.stringify(payload);
434
434
  }
435
- function taskSubmissionPayload(candidate, workspaceDir, reason) {
435
+ function taskSubmissionPayload(candidate, workspaceDir, details) {
436
+ const { reason, canonicalPainId } = details;
436
437
  if (candidate.kind === 'user_correction') {
437
438
  // P1-1 privacy (review round 2): sanitize the FULL text before any
438
439
  // truncation — a token crossing the 200-char boundary must be redacted, not
@@ -446,9 +447,14 @@ function taskSubmissionPayload(candidate, workspaceDir, reason) {
446
447
  hostKind: candidate.hostKind,
447
448
  // WorkBuddy explicit intake has no transcript observation to promote.
448
449
  // The classified Owner statement is its input basis, not a pseudo-trace.
449
- evidence: candidate.hostKind === 'workbuddy' && candidate.explicitOwnerStatement === true
450
- ? []
451
- : [{ sourceRef: `governance_observation:${candidate.logicalObservationKey}`, note: sanitizeString(candidate.text, workspaceDir) }],
450
+ evidence: candidate.hostKind === 'workbuddy'
451
+ ? candidate.explicitOwnerStatement === true ? [] : [{
452
+ // WorkBuddy has no governance_observation, anchor to the canonical
453
+ // Pain persisted in this same trajectory.db transaction.
454
+ sourceRef: 'pain_event:' + (canonicalPainId ?? ''),
455
+ note: sanitizeString(candidate.text, workspaceDir),
456
+ }]
457
+ : [{ sourceRef: 'governance_observation:' + candidate.logicalObservationKey, note: sanitizeString(candidate.text, workspaceDir) }],
452
458
  // SPEC-CODEX-PAIN-CLOSURE-001 CR-02 (merged from main): an explicit
453
459
  // Owner statement rides as classified correction evidence — never
454
460
  // summarized by an AI, never inside evidence[] pseudo-trace entries.
@@ -544,7 +550,7 @@ function admitCorrection({ db, candidate, workspaceDir, now, nowIso, keywordStor
544
550
  // detection, so `detection` is optional — no ruleVersion exists for them.
545
551
  ruleVersion: detection?.ruleVersion ?? null,
546
552
  reason,
547
- payloadJson: taskSubmissionPayload(candidate, workspaceDir, reason),
553
+ payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason, canonicalPainId: painId }),
548
554
  nowIso,
549
555
  });
550
556
  })();
@@ -637,7 +643,7 @@ function admitToolFailure({ db, candidate, workspaceDir, nowIso }) {
637
643
  canonicalPainId: painId,
638
644
  ruleVersion: null,
639
645
  reason: sanitizeString(reason, workspaceDir),
640
- payloadJson: taskSubmissionPayload(candidate, workspaceDir, reason),
646
+ payloadJson: taskSubmissionPayload(candidate, workspaceDir, { reason }),
641
647
  nowIso,
642
648
  });
643
649
  }
@@ -650,8 +656,8 @@ function admitToolFailure({ db, candidate, workspaceDir, nowIso }) {
650
656
  }
651
657
  return { disposition: 'admitted', kind: 'tool_failure', logicalObservationKey: candidate.logicalObservationKey, canonicalPainId: painId, duplicate, ruleVersion: null };
652
658
  }
653
- function admitOne({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDegradation }) {
654
- if (candidate.hostKind !== 'codex' && !(candidate.hostKind === 'workbuddy' && candidate.kind === 'user_correction' && candidate.explicitOwnerStatement === true)) {
659
+ function admitOne({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDegradation, trustedWorkbuddyLive }) {
660
+ if (candidate.hostKind !== 'codex' && !(candidate.hostKind === 'workbuddy' && candidate.kind === 'user_correction' && (candidate.explicitOwnerStatement === true || trustedWorkbuddyLive === true))) {
655
661
  return { ok: false, reason: 'unsupported_host_kind', nextAction: 'governance signal admission accepts Codex observations or explicit WorkBuddy Owner statements' };
656
662
  }
657
663
  const marker = db.prepare('SELECT * FROM governance_signal_admissions WHERE logical_observation_key = ?').get(candidate.logicalObservationKey);
@@ -676,7 +682,7 @@ function admitOne({ db, candidate, workspaceDir, now, nowIso, keywordStore, onDe
676
682
  * trajectory.db transaction (marker + pain row + quota together), so one
677
683
  * failing candidate never rolls back another's admission.
678
684
  */
679
- export function admitGovernanceSignals(input) {
685
+ function admitGovernanceSignalsInternal(input, trustedWorkbuddyLive = false) {
680
686
  const opened = openAdmissionStore(input.workspaceDir, input.databaseFactory);
681
687
  if (!('db' in opened))
682
688
  return opened;
@@ -691,7 +697,7 @@ export function admitGovernanceSignals(input) {
691
697
  const outcomes = [];
692
698
  try {
693
699
  for (const candidate of input.candidates) {
694
- outcomes.push(admitOne({ db, candidate, workspaceDir, now, nowIso, ...(input.keywordStore ? { keywordStore: input.keywordStore } : {}), ...(input.onDegradation ? { onDegradation: input.onDegradation } : {}) }));
700
+ outcomes.push(admitOne({ db, candidate, workspaceDir, now, nowIso, ...(input.keywordStore ? { keywordStore: input.keywordStore } : {}), ...(input.onDegradation ? { onDegradation: input.onDegradation } : {}), trustedWorkbuddyLive }));
695
701
  }
696
702
  return { ok: true, outcomes };
697
703
  }
@@ -703,6 +709,87 @@ export function admitGovernanceSignals(input) {
703
709
  close();
704
710
  }
705
711
  }
712
+ /** Public generic intake stays Codex + explicit WorkBuddy only. */
713
+ export function admitGovernanceSignals(input) {
714
+ return admitGovernanceSignalsInternal(input);
715
+ }
716
+ /** P0: a four-term projection of existing canonical high-precision seeds. */
717
+ const WORKBUDDY_AUTO_TERMS = new Set(['这是错的', '不要自作主张', '不应该这么做', '又搞错']);
718
+ const WORKBUDDY_MAX_PROMPT_BYTES = 16 * 1024;
719
+ function workbuddyP0KeywordStore() {
720
+ const seed = buildSharedSeedKeywordStore();
721
+ const terms = {};
722
+ for (const [term, def] of Object.entries(seed.terms)) {
723
+ if (WORKBUDDY_AUTO_TERMS.has(term) && def.category === 'correction' && def.precision === 'high') {
724
+ terms[term] = def;
725
+ }
726
+ }
727
+ return { version: seed.version, terms };
728
+ }
729
+ /** Pure check: no DB, no filesystem, no classifier, no historical transcript. */
730
+ export function assessWorkbuddyLiveKeyword(text, sessionId) {
731
+ if (Buffer.byteLength(text, 'utf8') > WORKBUDDY_MAX_PROMPT_BYTES) {
732
+ return { matched: false, reason: 'prompt_too_large' };
733
+ }
734
+ const store = workbuddyP0KeywordStore();
735
+ // Prefer precision to recall: reject quoted/embedded/reported phrases.
736
+ if (!Object.keys(store.terms).some(term => text.trimStart().toLowerCase().startsWith(term.toLowerCase()))) {
737
+ return { matched: false, reason: 'not_a_signal' };
738
+ }
739
+ const out = collectSync(text, sessionId, store, SYNC_DETECTION_CONFIG, new Date().toISOString());
740
+ const matched = out.isSignal && out.strength === 'STRONG' &&
741
+ out.type === 'correction' && out.matchedPrecision === 'high';
742
+ return { matched, reason: matched ? 'matched' : 'not_a_signal' };
743
+ }
744
+ /**
745
+ * Narrow native event gateway: validate true current user origin and flags.
746
+ * The ordinary generic admission API cannot bypass this WorkBuddy gate.
747
+ */
748
+ export function admitTrustedWorkbuddyLiveCorrection(input) {
749
+ const raw = input.rawEvent;
750
+ if (!isRecord(raw) || own(raw, 'hook_event_name') !== 'UserPromptSubmit' ||
751
+ own(raw, 'is_internal_continuation') !== false) {
752
+ return { ok: false, reason: 'native_current_user_unverified', nextAction: 'Require a real current WorkBuddy UserPromptSubmit event.' };
753
+ }
754
+ const sessionId = own(raw, 'session_id');
755
+ const generationId = own(raw, 'generation_id');
756
+ const prompt = own(raw, 'prompt');
757
+ if (typeof sessionId !== 'string' || !sessionId.trim() ||
758
+ typeof generationId !== 'string' || !generationId.trim() ||
759
+ typeof prompt !== 'string') {
760
+ return { ok: false, reason: 'native_identity_invalid', nextAction: 'Require nonblank native session_id/generation_id and prompt.' };
761
+ }
762
+ const config = loadPdConfigForPlugin(input.workspaceDir);
763
+ if (!config.ok)
764
+ return { ok: false, reason: 'workbuddy_config_invalid', nextAction: 'Repair WorkBuddy governance workspace config.' };
765
+ const { flags } = computeFeatureFlagsFromConfig(config.effective);
766
+ if (flags['host.workbuddy']?.enabled !== true || flags.workbuddy_live_keyword_pain?.enabled !== true) {
767
+ return { ok: false, reason: 'workbuddy_auto_disabled', nextAction: 'Explicitly enable both WorkBuddy host and live keyword Pain.' };
768
+ }
769
+ if (prompt.trimStart().startsWith('PD记录痛苦')) {
770
+ return { ok: false, reason: 'explicit_owner_statement_reserved', nextAction: 'Use original Owner intake; no keyword fallback.' };
771
+ }
772
+ if (!assessWorkbuddyLiveKeyword(prompt, sessionId).matched) {
773
+ return { ok: true, outcomes: [] };
774
+ }
775
+ const nativeIdentity = JSON.stringify(['workbuddy', sessionId, generationId]);
776
+ return admitGovernanceSignalsInternal({
777
+ workspaceDir: input.workspaceDir,
778
+ candidates: [{
779
+ kind: 'user_correction',
780
+ hostKind: 'workbuddy',
781
+ logicalObservationKey: nativeIdentity + '|user',
782
+ rolloutIdentity: JSON.stringify(['workbuddy', sessionId]),
783
+ rootSessionId: sessionId,
784
+ hostTurnId: nativeIdentity,
785
+ text: prompt,
786
+ observedAt: (input.now ?? new Date()).toISOString(),
787
+ }],
788
+ keywordStore: workbuddyP0KeywordStore(),
789
+ ...(input.now ? { now: input.now } : {}),
790
+ ...(input.databaseFactory ? { databaseFactory: input.databaseFactory } : {}),
791
+ }, true);
792
+ }
706
793
  // ─── Diagnostician continuation (one pain → exactly one pending task) ───────
707
794
  function isGovernanceTaskPayload(value) {
708
795
  if (!isRecord(value))
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';
@@ -211,6 +211,26 @@ export function createProductionHostRuntime(options = {}) {
211
211
  const prompt = await buildActivePrinciplePromptContext({
212
212
  workspaceDir: event.context.workspaceDir,
213
213
  excludePrincipleIds: options.promptExcludePrincipleIds?.(event),
214
+ promptBudget: resolvePromptBudget({
215
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: resolve ONCE per request
216
+ // from the host's own hook facts (OpenClaw effective window /
217
+ // Codex+WorkBuddy model id) against the installed local Pi AI
218
+ // catalog — no network, no LLM call. Missing/untrusted facts
219
+ // resolve to the legacy fixed budget with an observable reason.
220
+ hostCapacity: event.context.modelContext === undefined
221
+ ? undefined
222
+ : {
223
+ contextTokenBudget: event.context.modelContext.contextTokenBudget,
224
+ contextWindowSource: event.context.modelContext.contextWindowSource,
225
+ },
226
+ modelIdentity: event.context.modelContext === undefined
227
+ ? undefined
228
+ : {
229
+ providerId: event.context.modelContext.modelProviderId,
230
+ modelId: event.context.modelContext.modelId,
231
+ },
232
+ catalog: getPromptModelCatalog(),
233
+ }),
214
234
  });
215
235
  // PRI-750: record the injection event on the shared path with the host
216
236
  // turn id (Codex turn_id → runId) so receipt events carry a turn-level
@@ -231,11 +251,22 @@ export function createProductionHostRuntime(options = {}) {
231
251
  injectedCount: prompt.principleIds.length,
232
252
  skippedWarnings: prompt.warnings,
233
253
  injectedCharCount: prompt.additionalContext.length,
234
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
254
+ budget: prompt.budget,
235
255
  ...(options.hostKind === 'openclaw' || options.hostKind === 'codex' || options.hostKind === 'workbuddy' ? { hostKind: options.hostKind } : {}),
236
256
  eventId: randomUUID(),
237
257
  ...(prompt.truncated !== undefined ? { v2Truncated: prompt.truncated } : {}),
238
258
  ...(event.context.turnId !== undefined ? { runId: event.context.turnId } : {}),
259
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: budget provenance — the
260
+ // recorded budget is the one THIS injection actually ran under
261
+ // (prompt.budget), never a constant restated after the fact.
262
+ ...(prompt.budgetPolicyVersion !== undefined ? { budgetPolicyVersion: prompt.budgetPolicyVersion } : {}),
263
+ ...(prompt.budgetCapacitySource !== undefined ? { budgetCapacitySource: prompt.budgetCapacitySource } : {}),
264
+ ...(prompt.budgetCapacityStatus !== undefined ? { budgetCapacityStatus: prompt.budgetCapacityStatus } : {}),
265
+ ...(prompt.budgetContextWindowTokens != null ? { budgetContextWindowTokens: prompt.budgetContextWindowTokens } : {}),
266
+ // SPEC §7 v0.2: the bounded fallback reason code rides the event so
267
+ // operators can tell model-identity absence from catalog ambiguity
268
+ // etc. without touching logs. Detail stays out of events.
269
+ ...(prompt.budgetFallbackReason !== undefined ? { budgetFallbackReason: prompt.budgetFallbackReason } : {}),
239
270
  // PRI-904: selection diagnostics (bounded, optional). On this route
240
271
  // the policy is legacy_fifo_prefix_v1 and NO rotation provenance is
241
272
  // 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',
@@ -1,4 +1,4 @@
1
- import { type PromptSelectionPolicy } from '@principles/core/runtime-v2';
1
+ import { type PromptBudgetResolution, type PromptSelectionPolicy } from '@principles/core/runtime-v2';
2
2
  /** Hosts with a production prompt-injection path today (shared GovernanceHostKind boundary). */
3
3
  export type PromptInjectionTargetHost = 'openclaw' | 'codex' | 'workbuddy';
4
4
  /** Single host roster (review P2): every consumer derives from this list, so a
@@ -140,6 +140,20 @@ export interface PromptInjectionProjection {
140
140
  * route (where `usedChars` already IS the full render).
141
141
  */
142
142
  fullRenderChars?: number;
143
+ /**
144
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1/§7: provenance of `budget` —
145
+ * the same policy/resolution the real injection runs, so consumers can
146
+ * tell "runtime capacity confirmed" apart from "fixed-budget fallback"
147
+ * instead of assuming the forecast matches every model.
148
+ */
149
+ budgetCapacitySource?: PromptBudgetResolution['capacitySource'];
150
+ budgetCapacityStatus?: PromptBudgetResolution['capacityStatus'];
151
+ budgetContextWindowTokens?: number | null;
152
+ budgetPolicyVersion?: PromptBudgetResolution['policyVersion'];
153
+ /** SPEC §7 v0.2: bounded fallback reason code; present only on legacy_fallback. */
154
+ budgetFallbackReason?: PromptBudgetResolution['fallbackReason'];
155
+ /** Non-sensitive log context; never emitted to events. */
156
+ budgetFallbackDetail?: string;
143
157
  }
144
158
  /**
145
159
  * PR #1844 follow-up: forecast the prompt-injection budget from the SAME
@@ -186,6 +200,14 @@ export declare function buildLivePromptInjectionProjection(input: {
186
200
  roundKey?: number;
187
201
  /** AC-01: explicit route binding — 'codex'/'workbuddy' always shared, 'openclaw' by flag. */
188
202
  targetHost?: PromptInjectionTargetHost;
203
+ /**
204
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.1: budget resolution this
205
+ * projection must run under. Absent → the fixed-fallback default (the
206
+ * honest "no runtime capacity confirmed" policy), NOT a silent second
207
+ * policy. Callers holding live model facts (the plugin hook) pass the
208
+ * SAME resolution the injection uses.
209
+ */
210
+ promptBudget?: PromptBudgetResolution;
189
211
  }): Promise<PromptInjectionProjection>;
190
212
  /**
191
213
  * AC-01/R-A1: the host-aware entry every console/CLI consumer should use.
@@ -1,4 +1,4 @@
1
- import { RUNTIME_V2_PRINCIPLE_BUDGET, computeFeatureFlagsFromConfig, renderPrinciplesToDirectives, trimToBudget, } from '@principles/core/runtime-v2';
1
+ import { computeFeatureFlagsFromConfig, renderPrinciplesToDirectives, resolvePromptBudget, trimToBudget, } from '@principles/core/runtime-v2';
2
2
  import { escapeXml } from '@principles/core/prompt-builder';
3
3
  import { loadPdConfigForPlugin } from './pd-config.js';
4
4
  import { buildActivePrinciplePromptContext, readPromptActivationCandidates, MAX_SHARED_DIAGNOSTIC_IDS } from './active-principle-prompt.js';
@@ -223,6 +223,7 @@ function isDiagnosticCapHit(ids) {
223
223
  * `selectionPolicy` on the result.
224
224
  */
225
225
  export async function buildLivePromptInjectionProjection(input) {
226
+ const promptBudget = input.promptBudget ?? resolvePromptBudget();
226
227
  const flagEnabled = computeFeatureFlagsFromConfig(loadPdConfigForPlugin(input.workspaceDir).effective).flags.abstraction_layer_v1?.enabled === true;
227
228
  // Route face follows THE one route formula (review P2-2: a re-typed
228
229
  // codex-only ternary forecast WorkBuddy as legacy_trim while its adapter
@@ -232,7 +233,7 @@ export async function buildLivePromptInjectionProjection(input) {
232
233
  ? flagEnabled
233
234
  : promptRouteForHost(input.targetHost, flagEnabled) === 'shared_render';
234
235
  if (sharedRoute) {
235
- const context = await buildActivePrinciplePromptContext(input);
236
+ const context = await buildActivePrinciplePromptContext({ ...input, promptBudget });
236
237
  const oversized = context.oversizedActivationIds ?? [];
237
238
  const dropped = context.droppedActivationIds ?? [];
238
239
  return {
@@ -256,6 +257,12 @@ export async function buildLivePromptInjectionProjection(input) {
256
257
  droppedActivationIds: dropped,
257
258
  oversizedDiagnosticTruncated: isDiagnosticCapHit(oversized),
258
259
  droppedDiagnosticTruncated: isDiagnosticCapHit(dropped),
260
+ budgetCapacitySource: context.budgetCapacitySource,
261
+ budgetCapacityStatus: context.budgetCapacityStatus,
262
+ budgetContextWindowTokens: context.budgetContextWindowTokens,
263
+ budgetPolicyVersion: context.budgetPolicyVersion,
264
+ ...(context.budgetFallbackReason !== undefined ? { budgetFallbackReason: context.budgetFallbackReason } : {}),
265
+ ...(context.budgetFallbackDetail !== undefined ? { budgetFallbackDetail: context.budgetFallbackDetail } : {}),
259
266
  };
260
267
  }
261
268
  const candidates = await readPromptActivationCandidates(input);
@@ -263,7 +270,7 @@ export async function buildLivePromptInjectionProjection(input) {
263
270
  if (candidates.aborted || principles.length === 0) {
264
271
  return {
265
272
  route: 'legacy_trim',
266
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
273
+ budget: promptBudget.budgetChars,
267
274
  usedChars: 0,
268
275
  truncated: false,
269
276
  injectedPrincipleIds: [],
@@ -279,11 +286,17 @@ export async function buildLivePromptInjectionProjection(input) {
279
286
  droppedActivationIds: [],
280
287
  oversizedDiagnosticTruncated: false,
281
288
  droppedDiagnosticTruncated: false,
289
+ budgetCapacitySource: promptBudget.capacitySource,
290
+ budgetCapacityStatus: promptBudget.capacityStatus,
291
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
292
+ budgetPolicyVersion: promptBudget.policyVersion,
293
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
294
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
282
295
  };
283
296
  }
284
297
  // PR-1894: pass the caller's round key so the forecast runs the SAME
285
298
  // packing policy production runs this turn.
286
- const trimmed = trimToBudget(principles, RUNTIME_V2_PRINCIPLE_BUDGET, escapeXml, input.roundKey);
299
+ const trimmed = trimToBudget(principles, promptBudget.budgetChars, escapeXml, input.roundKey);
287
300
  const injected = principles.filter((p) => trimmed.injectedIds.has(p.principleId));
288
301
  // PR-1894: the legacy_trim route IS the OpenClaw plugin-local route, which
289
302
  // rotates on every recorded user turn (PRI-904). Reachability is therefore
@@ -291,11 +304,11 @@ export async function buildLivePromptInjectionProjection(input) {
291
304
  // round key — a console without a session key must still be able to tell
292
305
  // "queued behind rotation" apart from "structurally starved", otherwise it
293
306
  // keeps telling the Owner to deactivate healthy principles.
294
- const eventuallyInjectedActivationIds = collectReachableActivationIds(principles, RUNTIME_V2_PRINCIPLE_BUDGET);
307
+ const eventuallyInjectedActivationIds = collectReachableActivationIds(principles, promptBudget.budgetChars);
295
308
  const fullRenderChars = renderPrinciplesToDirectives(injected, trimmed.injectedIds, { escapeFn: escapeXml, selfReportInstruction: candidates.selfReportEnabled }).length;
296
309
  return {
297
310
  route: 'legacy_trim',
298
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
311
+ budget: promptBudget.budgetChars,
299
312
  usedChars: trimmed.lines.join('\n').length,
300
313
  truncated: trimmed.truncated,
301
314
  injectedPrincipleIds: [...trimmed.injectedIds],
@@ -312,6 +325,12 @@ export async function buildLivePromptInjectionProjection(input) {
312
325
  oversizedDiagnosticTruncated: isDiagnosticCapHit(trimmed.oversizedActivationIds),
313
326
  droppedDiagnosticTruncated: isDiagnosticCapHit(trimmed.droppedActivationIds),
314
327
  ...(injected.length > 0 ? { fullRenderChars } : {}),
328
+ budgetCapacitySource: promptBudget.capacitySource,
329
+ budgetCapacityStatus: promptBudget.capacityStatus,
330
+ budgetContextWindowTokens: promptBudget.contextWindowTokens,
331
+ budgetPolicyVersion: promptBudget.policyVersion,
332
+ ...(promptBudget.fallbackReason !== undefined ? { budgetFallbackReason: promptBudget.fallbackReason } : {}),
333
+ ...(promptBudget.fallbackDetail !== undefined ? { budgetFallbackDetail: promptBudget.fallbackDetail } : {}),
315
334
  };
316
335
  }
317
336
  export async function resolveLivePromptInjectionProjection(input) {
@@ -1,3 +1,4 @@
1
+ import { type PromptBudgetTierFit } from '@principles/core/runtime-v2';
1
2
  import { type PromptInjectionBudgetScope, type PromptInjectionRouteDecision, type PromptInjectionRouteName, type PromptInjectionTargetHost } from './prompt-injection-projection.js';
2
3
  /**
3
4
  * SPEC PD_PROMPT_CAPACITY_V1 R-A3: per-live-activation injection status for
@@ -9,25 +10,39 @@ import { type PromptInjectionBudgetScope, type PromptInjectionRouteDecision, typ
9
10
  * `route_unconfirmed` surfaces AC-01's honest unknown instead of a guess.
10
11
  * No compliance/遵守 counts are produced here — absence of injection logs is
11
12
  * NOT reported as "zero compliance" (AC-12 boundary).
13
+ *
14
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: "oversized" now means the entry
15
+ * exceeds the MAXIMUM supported budget (never injectable on any supported
16
+ * model); a row that misses the CURRENT projection budget but fits within
17
+ * the supported [min, max] range is `model_dependent` — whether it injects
18
+ * depends on the runtime model's context window, which this surface cannot
19
+ * see. Budget figures come from the projection's resolution (legacy
20
+ * fallback when no runtime capacity is confirmed), never a bare constant.
12
21
  */
13
- export type PromptActivationInjectionStatusKind = 'injectable' | 'oversized' | 'window_excluded' | 'resolution_failed' | 'route_unconfirmed';
22
+ export type PromptActivationInjectionStatusKind = 'injectable' | 'oversized' | 'model_dependent' | 'window_excluded' | 'resolution_failed' | 'route_unconfirmed';
14
23
  export interface PromptActivationInjectionStatus {
15
24
  activationId: string;
16
25
  principleId?: string;
17
26
  status: PromptActivationInjectionStatusKind;
18
- /** Present for injectable/window_excluded/oversized under a confirmed route. */
27
+ /**
28
+ * Present for injectable/window_excluded/oversized/model_dependent under a
29
+ * confirmed route. For model_dependent rows it keeps the POSITIVE window
30
+ * fact: true = the entry fits THIS surface's (usually fallback) projection
31
+ * window, but still exceeds the measured floor — a smaller-context runtime
32
+ * model would exclude it.
33
+ */
19
34
  inCurrentWindow?: boolean;
20
35
  /** Present when the single-item cost was computable. */
21
36
  costChars?: number;
22
37
  budget?: number;
23
38
  budgetScope?: PromptInjectionBudgetScope;
24
39
  route?: PromptInjectionRouteName;
25
- /** Present for resolution_failed / route_unconfirmed (structured reason). */
40
+ /** Present for resolution_failed / route_unconfirmed / model_dependent (structured reason). */
26
41
  reason?: string;
27
- /** Present for route_unconfirmed: per-host single-item fit facts. */
42
+ /** Present for route_unconfirmed: per-host single-item tier fit facts. */
28
43
  perHostFits?: readonly {
29
44
  hostKind: PromptInjectionTargetHost;
30
- fits: boolean;
45
+ tierFit: PromptBudgetTierFit;
31
46
  }[];
32
47
  }
33
48
  export interface PromptActivationInjectionStatusReport {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { RUNTIME_V2_PRINCIPLE_BUDGET, SqliteActivationStateStore, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, filterPromptActivations, resolvePrincipleFromArtifact, } from '@principles/core/runtime-v2';
3
+ import { PROMPT_BUDGET_MAX_CHARS, PROMPT_BUDGET_MIN_CHARS, SqliteActivationStateStore, SqliteConnection, SqlitePIArtifactStore, classifySingleItemBudgetTier, computeFeatureFlagsFromConfig, filterPromptActivations, resolvePrincipleFromArtifact, } from '@principles/core/runtime-v2';
4
4
  import { loadPdConfigForPlugin } from './pd-config.js';
5
5
  import { computePromptSingleItemFit } from './prompt-activation-deliverability.js';
6
6
  import { PROMPT_INJECTION_TARGET_HOSTS, buildLivePromptInjectionProjection, promptRouteForHost, resolvePromptInjectionRouteDecision, } from './prompt-injection-projection.js';
@@ -86,12 +86,12 @@ export async function readPromptActivationInjectionStatuses(input) {
86
86
  }
87
87
  const perHostFits = PROMPT_INJECTION_TARGET_HOSTS.map((hostKind) => {
88
88
  const hostRoute = promptRouteForHost(hostKind, route.flagEnabled);
89
- const { fits } = computePromptSingleItemFit({
89
+ const { costChars } = computePromptSingleItemFit({
90
90
  principle: { principleId: row.principleId, text: row.text, artifactId: '', activationId: row.activationId },
91
91
  route: hostRoute,
92
92
  selfReportEnabled,
93
93
  });
94
- return { hostKind, fits };
94
+ return { hostKind, tierFit: classifySingleItemBudgetTier(costChars) };
95
95
  });
96
96
  return {
97
97
  activationId: row.activationId,
@@ -116,20 +116,43 @@ export async function readPromptActivationInjectionStatuses(input) {
116
116
  principle: { principleId: row.principleId, text: row.text, artifactId: '', activationId: row.activationId },
117
117
  route: projection.route,
118
118
  selfReportEnabled,
119
+ budget: projection.budget,
119
120
  });
120
121
  const inCurrentWindow = projection.injectedActivationIds.includes(row.activationId);
121
122
  const budgetScope = projection.budgetScope;
122
- if (!fits) {
123
+ const serialization = projection.route === 'shared_render' ? 'full directive render' : 'list entry';
124
+ // 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.
131
+ const tierFit = classifySingleItemBudgetTier(costChars);
132
+ if (tierFit === 'exceeds_max_tier') {
123
133
  return {
124
134
  activationId: row.activationId,
125
135
  principleId: row.principleId,
126
136
  status: 'oversized',
127
137
  inCurrentWindow: false,
128
138
  costChars,
129
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
139
+ budget: projection.budget,
130
140
  budgetScope,
131
141
  route: projection.route,
132
- reason: `single_item_exceeds_budget: this principle's own entry is ${costChars} chars > ${RUNTIME_V2_PRINCIPLE_BUDGET} under the ${projection.route === 'shared_render' ? 'full directive render' : 'list entry'} serialization`,
142
+ reason: `single_item_exceeds_budget: this principle's own entry is ${costChars} chars > the ${PROMPT_BUDGET_MAX_CHARS}-char maximum supported budget under the ${serialization} serialization`,
143
+ };
144
+ }
145
+ if (tierFit === 'model_dependent') {
146
+ return {
147
+ activationId: row.activationId,
148
+ principleId: row.principleId,
149
+ status: 'model_dependent',
150
+ inCurrentWindow,
151
+ costChars,
152
+ budget: projection.budget,
153
+ budgetScope,
154
+ route: projection.route,
155
+ reason: `model_dependent_capacity: this principle's own entry is ${costChars} chars — above the ${PROMPT_BUDGET_MIN_CHARS}-char measured floor, so smaller-context models exclude it${fits ? ' even though it fits this surface\'s fallback-budget estimate' : ''}; injection depends on the runtime model`,
133
156
  };
134
157
  }
135
158
  return {
@@ -138,7 +161,7 @@ export async function readPromptActivationInjectionStatuses(input) {
138
161
  status: inCurrentWindow ? 'injectable' : 'window_excluded',
139
162
  inCurrentWindow,
140
163
  costChars,
141
- budget: RUNTIME_V2_PRINCIPLE_BUDGET,
164
+ budget: projection.budget,
142
165
  budgetScope,
143
166
  route: projection.route,
144
167
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "lint": "eslint \"src/**/*.ts\""
32
32
  },
33
33
  "dependencies": {
34
- "@principles/core": "^1.293.5",
34
+ "@principles/core": "^1.295.0",
35
35
  "@principles/install-layout": "^0.4.0",
36
36
  "better-sqlite3": "^13.0.3",
37
37
  "js-yaml": "^5.4.3"