create-principles-disciple 1.145.11 → 1.145.13

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.
Files changed (74) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/codex-adapter/dist/codec/input-decoder.js +11 -3
  3. package/codex-adapter/package.json +1 -1
  4. package/console/dist/server/models/ActivationsConsoleModel.d.ts +3 -1
  5. package/console/dist/server/models/ActivationsConsoleModel.js +39 -5
  6. package/console/dist/server/models/ApprovalsConsoleModel.d.ts +1 -1
  7. package/console/dist/server/models/ApprovalsConsoleModel.js +64 -8
  8. package/console/dist/server/models/ApprovalsGroupedConsoleModel.d.ts +25 -1
  9. package/console/dist/server/models/ApprovalsGroupedConsoleModel.js +116 -24
  10. package/console/dist/server/routes/approvals-grouped.js +5 -2
  11. package/console/dist/shared/prompt-injection-contract.d.ts +83 -2
  12. package/console/dist/ui/i18n/en.json +25 -1
  13. package/console/dist/ui/i18n/zh-CN.json +25 -1
  14. package/console/dist/ui/pages/activation/ActivationPage.js +56 -5
  15. package/console/dist/ui/pages/focus/FocusPage.js +87 -21
  16. package/console/dist/ui/pages/focus/focus-validation.js +12 -0
  17. package/console/dist/ui/utils/validators.d.ts +6 -0
  18. package/console/dist/ui/utils/validators.js +93 -4
  19. package/console/dist/web/assets/app.js +24 -24
  20. package/core/dist/host/host-adapter.d.ts +27 -0
  21. package/core/dist/host/host-adapter.d.ts.map +1 -1
  22. package/core/dist/host/host-adapter.js.map +1 -1
  23. package/core/dist/host/index.d.ts +1 -1
  24. package/core/dist/host/index.d.ts.map +1 -1
  25. package/core/dist/host/index.js.map +1 -1
  26. package/core/dist/runtime-v2/activation/index.d.ts +2 -0
  27. package/core/dist/runtime-v2/activation/index.d.ts.map +1 -1
  28. package/core/dist/runtime-v2/activation/index.js +2 -0
  29. package/core/dist/runtime-v2/activation/index.js.map +1 -1
  30. package/core/dist/runtime-v2/activation/prompt-budget-policy.d.ts +106 -0
  31. package/core/dist/runtime-v2/activation/prompt-budget-policy.d.ts.map +1 -0
  32. package/core/dist/runtime-v2/activation/prompt-budget-policy.js +229 -0
  33. package/core/dist/runtime-v2/activation/prompt-budget-policy.js.map +1 -0
  34. package/core/dist/runtime-v2/adapter/pi-ai-catalog.d.ts +2 -0
  35. package/core/dist/runtime-v2/adapter/pi-ai-catalog.d.ts.map +1 -1
  36. package/core/dist/runtime-v2/adapter/pi-ai-catalog.js +50 -1
  37. package/core/dist/runtime-v2/adapter/pi-ai-catalog.js.map +1 -1
  38. package/core/dist/runtime-v2/index.d.ts +3 -3
  39. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  40. package/core/dist/runtime-v2/index.js +2 -2
  41. package/core/dist/runtime-v2/index.js.map +1 -1
  42. package/core/dist/runtime-v2/types/event-types.d.ts +24 -0
  43. package/core/dist/runtime-v2/types/event-types.d.ts.map +1 -1
  44. package/core/dist/runtime-v2/types/event-types.js +22 -0
  45. package/core/dist/runtime-v2/types/event-types.js.map +1 -1
  46. package/core/package.json +1 -1
  47. package/host-runtime/dist/active-principle-prompt.d.ts +23 -1
  48. package/host-runtime/dist/active-principle-prompt.js +21 -5
  49. package/host-runtime/dist/index.d.ts +1 -0
  50. package/host-runtime/dist/index.js +37 -2
  51. package/host-runtime/dist/prompt-activation-deliverability.d.ts +36 -3
  52. package/host-runtime/dist/prompt-activation-deliverability.js +44 -26
  53. package/host-runtime/dist/prompt-injection-budget-observation.d.ts +176 -0
  54. package/host-runtime/dist/prompt-injection-budget-observation.js +449 -0
  55. package/host-runtime/dist/prompt-injection-projection.d.ts +39 -1
  56. package/host-runtime/dist/prompt-injection-projection.js +43 -9
  57. package/host-runtime/dist/prompt-injection-status.d.ts +47 -5
  58. package/host-runtime/dist/prompt-injection-status.js +45 -8
  59. package/host-runtime/package.json +1 -1
  60. package/package.json +2 -2
  61. package/pd-cli/dist/commands/runtime-activation.d.ts +3 -1
  62. package/pd-cli/dist/commands/runtime-activation.d.ts.map +1 -1
  63. package/pd-cli/dist/commands/runtime-activation.js +23 -6
  64. package/pd-cli/dist/commands/runtime-activation.js.map +1 -1
  65. package/pd-cli/package.json +1 -1
  66. package/plugin/dist/bundle.js +430 -430
  67. package/plugin/dist/governance-audit.js +2 -2
  68. package/plugin/dist/openclaw-sdk.d.ts +9 -1
  69. package/plugin/dist/openclaw.plugin.json +1 -1
  70. package/plugin/openclaw.plugin.json +1 -1
  71. package/plugin/package.json +1 -1
  72. package/release-manager/package.json +2 -2
  73. package/workbuddy-adapter/dist/codec/input-decoder.js +16 -4
  74. package/workbuddy-adapter/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "productVersion": "2.3.8",
4
- "sourceCommit": "ec66d23385b88a7f36442702ea862432435aa470"
3
+ "productVersion": "2.3.9",
4
+ "sourceCommit": "6f9ce4614572e0ad439801a9d86fe398a0781752"
5
5
  }
@@ -59,10 +59,15 @@ function validateCommon(raw, needsTurn) {
59
59
  const sessionId = requiredString(raw, 'session_id');
60
60
  requiredNullableString(raw, 'transcript_path');
61
61
  const workspaceDir = requiredString(raw, 'cwd');
62
- requiredString(raw, 'model');
62
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: keep the VALIDATED model identity
63
+ // (was: checked then discarded). It is an identity string only — never a
64
+ // capacity; the budget resolver turns it into a budget via the installed
65
+ // catalog, falling back to the fixed budget on unknown/ambiguous ids. It
66
+ // must not block the hook or leak into tool allow/deny decisions.
67
+ const modelId = requiredString(raw, 'model');
63
68
  requiredString(raw, 'permission_mode');
64
69
  const turnId = needsTurn ? requiredString(raw, 'turn_id') : undefined;
65
- return { workspaceDir, sessionId, ...(turnId ? { turnId } : {}) };
70
+ return { base: { workspaceDir, sessionId, ...(turnId ? { turnId } : {}) }, modelId };
66
71
  }
67
72
  export function decodeCodexInput(raw) {
68
73
  if (!isRecord(raw))
@@ -71,7 +76,10 @@ export function decodeCodexInput(raw) {
71
76
  const kind = EVENT_KINDS.get(eventName);
72
77
  if (!kind)
73
78
  throw new CodexDecoderError(`unknown hook_event_name "${eventName}"`, 'Configure only PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, or Stop.');
74
- const common = validateCommon(raw, kind !== 'session_start');
79
+ const { base, modelId } = validateCommon(raw, kind !== 'session_start');
80
+ // Model identity rides every event context under modelContext (budget
81
+ // resolution happens on the prompt path; other paths carry it harmlessly).
82
+ const common = { ...base, modelContext: { modelId } };
75
83
  let context;
76
84
  let rawPayload = raw;
77
85
  if (kind === 'before_tool_call' || kind === 'after_tool_call') {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/codex-adapter",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Codex CLI host adapter for Principles Disciple — implements HostAdapter interface for OpenAI Codex CLI's stdin/stdout JSON hook model (ADR-0020).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,7 +127,9 @@ export interface OwnerMutationInput {
127
127
  * that path made every activation's shadow metrics report "unavailable" in the
128
128
  * console while real evaluations accumulated unread in `.state/logs`. Readers
129
129
  * scan both candidates until the writer migrates (ERR-031: both readers derive
130
- * from this same list).
130
+ * from this same list — the single source is the host-runtime constant
131
+ * `RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS`, shared with the PRI-1017 budget
132
+ * observation reader so the two readers can never drift).
131
133
  */
132
134
  export declare const RULECODE_EVENT_LOG_CANDIDATE_DIRS: readonly string[];
133
135
  export interface CollectedRuleCodeEventEntries {
@@ -1,5 +1,5 @@
1
1
  import { SqliteConnection, SqliteActivationStateStore, SqlitePIArtifactStore, extractPrincipleId, extractEvidenceRefs, PromotionReadinessReader, RuleHostWriter, createProductionGateDeps, SqliteActivationSafetyStore, isFeatureEnabled, RuleCodeOwnerDecisionService, collectOpenClawPromotionChecks, summarizeRuleCodeShadowEvents, buildPromotionEvidenceSnapshot, } from '@principles/core/runtime-v2';
2
- import { resolvePromotionHostLiveness, resolveWorkspaceHostToolSemantics, readPromptActivationInjectionStatuses } from '@principles/host-runtime';
2
+ import { resolvePromotionHostLiveness, resolveWorkspaceHostToolSemantics, readPromptActivationInjectionStatuses, runtimeBudgetObservationToResolution, selectConsoleRuntimeBudgetFacts, RUNTIME_BUDGET_OBSERVATION_STALE_REASON, RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS } from '@principles/host-runtime';
3
3
  import { loadPdConfig, computeFlagsFromLoadResult } from '../config/pd-config-store.js';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
@@ -63,9 +63,11 @@ function unavailableShadowSummary() {
63
63
  * that path made every activation's shadow metrics report "unavailable" in the
64
64
  * console while real evaluations accumulated unread in `.state/logs`. Readers
65
65
  * scan both candidates until the writer migrates (ERR-031: both readers derive
66
- * from this same list).
66
+ * from this same list — the single source is the host-runtime constant
67
+ * `RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS`, shared with the PRI-1017 budget
68
+ * observation reader so the two readers can never drift).
67
69
  */
68
- export const RULECODE_EVENT_LOG_CANDIDATE_DIRS = ['.pd/logs', '.state/logs'];
70
+ export const RULECODE_EVENT_LOG_CANDIDATE_DIRS = RUNTIME_V2_EVENT_LOG_CANDIDATE_DIRS;
69
71
  /**
70
72
  * Collect rulehost telemetry entries from all candidate log directories.
71
73
  * Malformed lines are excluded individually. A directory counts as a source
@@ -318,10 +320,33 @@ export class ActivationsConsoleModel {
318
320
  // status with positive single-item evidence. Degradation is observable
319
321
  // (route summary carries the reason) and never turns a row "healthy"
320
322
  // by omission (rc-9 / AC-06).
323
+ // PRI-1017: the status projection runs under the workspace's most
324
+ // recent runtime budget OBSERVATION for the target host, so per-row
325
+ // `budget` figures match the budget the last real injection used —
326
+ // SPEC §6.1's same-budget rule for this surface too.
321
327
  let promptInjectionRoute;
322
328
  let statusByActivation;
323
329
  try {
324
- const report = await readPromptActivationInjectionStatuses({ workspaceDir: this.workspaceDir, targetHost });
330
+ const budgetFacts = selectConsoleRuntimeBudgetFacts({
331
+ workspaceDir: this.workspaceDir,
332
+ ...(targetHost !== undefined ? { targetHost } : {}),
333
+ });
334
+ const perHostBudget = new Map([...budgetFacts.perHost].map(([hostKind, observation]) => [hostKind, runtimeBudgetObservationToResolution(observation)]));
335
+ const report = await readPromptActivationInjectionStatuses({
336
+ workspaceDir: this.workspaceDir,
337
+ ...(targetHost !== undefined ? { targetHost } : {}),
338
+ ...(budgetFacts.selected !== undefined
339
+ ? { promptBudget: runtimeBudgetObservationToResolution(budgetFacts.selected) }
340
+ : {}),
341
+ ...(perHostBudget.size > 0 ? { perHostPromptBudget: Object.fromEntries(perHostBudget) } : {}),
342
+ });
343
+ // The status report resolved the route internally; bind the observation
344
+ // to THAT host only. A stale observation is never presented as current.
345
+ const decidedHost = report.route.status === 'confirmed' ? report.route.hostKind : undefined;
346
+ const usedObservation = decidedHost !== undefined ? budgetFacts.perHost.get(decidedHost) : undefined;
347
+ const staleObservation = decidedHost !== undefined && usedObservation === undefined
348
+ ? budgetFacts.stalePerHost.get(decidedHost)
349
+ : undefined;
325
350
  promptInjectionRoute = {
326
351
  status: report.route.status,
327
352
  ...(report.route.hostKind !== undefined ? { hostKind: report.route.hostKind } : {}),
@@ -330,6 +355,15 @@ export class ActivationsConsoleModel {
330
355
  ...(report.route.unconfirmedReason !== undefined ? { unconfirmedReason: report.route.unconfirmedReason } : {}),
331
356
  ...(report.route.nextAction !== undefined ? { nextAction: report.route.nextAction } : {}),
332
357
  ...(report.globalReason !== undefined ? { globalReason: report.globalReason } : {}),
358
+ ...(report.budgetProvenance?.capacitySource !== undefined ? { budgetCapacitySource: report.budgetProvenance.capacitySource } : {}),
359
+ ...(report.budgetProvenance?.capacityStatus !== undefined ? { budgetCapacityStatus: report.budgetProvenance.capacityStatus } : {}),
360
+ ...(report.budgetProvenance?.contextWindowTokens !== undefined ? { budgetContextWindowTokens: report.budgetProvenance.contextWindowTokens } : {}),
361
+ ...(staleObservation !== undefined
362
+ ? { budgetFallbackReason: RUNTIME_BUDGET_OBSERVATION_STALE_REASON }
363
+ : report.budgetProvenance?.fallbackReason !== undefined
364
+ ? { budgetFallbackReason: report.budgetProvenance.fallbackReason }
365
+ : {}),
366
+ ...(usedObservation !== undefined ? { budgetObservedAt: usedObservation.observedAt } : {}),
333
367
  };
334
368
  statusByActivation = new Map(report.statuses.map((entry) => [
335
369
  entry.activationId,
@@ -343,7 +377,7 @@ export class ActivationsConsoleModel {
343
377
  ...(entry.route !== undefined ? { route: entry.route } : {}),
344
378
  ...(entry.reason !== undefined ? { reason: entry.reason } : {}),
345
379
  ...(entry.perHostFits !== undefined
346
- ? { perHostFits: entry.perHostFits.map((fit) => ({ hostKind: fit.hostKind, fits: fit.fits })) }
380
+ ? { perHostFits: entry.perHostFits.map((fit) => ({ hostKind: fit.hostKind, tierFit: fit.tierFit })) }
347
381
  : {}),
348
382
  },
349
383
  ]));
@@ -84,7 +84,7 @@ export type ApproveWithActivationResult = {
84
84
  overByChars?: number;
85
85
  perHost?: {
86
86
  hostKind: 'openclaw' | 'codex' | 'workbuddy';
87
- fits: boolean;
87
+ tierFit: 'fits_all_tiers' | 'model_dependent' | 'exceeds_max_tier';
88
88
  costChars: number;
89
89
  }[];
90
90
  };
@@ -3,7 +3,7 @@ import * as path from 'node:path';
3
3
  import { SqliteConnection, SqliteApprovalQueueStore, SqliteActivationStateStore, SqlitePIArtifactStore, ActivationDispatcher, PromptWriter, DeferArchiveWriter, RuleHostWriter, createProductionGateDeps, ApprovalCompletionService, ApprovalQueue, mapConfidenceToLabel, isArtifactRevisionOf, detectPromptReplacementTarget, buildOwnerRevisionArtifact, PrincipleTreeLedgerAdapter, MVP_CHANNELS, } from '@principles/core/runtime-v2';
4
4
  import { resolveLedgerPrincipleId } from './principle-id-resolution.js';
5
5
  import { loadPdConfig, computeFlagsFromLoadResult } from '../config/pd-config-store.js';
6
- import { resolveWorkspaceHostToolSemantics, checkPromptArtifactDeliverabilityById, resolveLivePromptInjectionProjection, } from '@principles/host-runtime';
6
+ import { resolveWorkspaceHostToolSemantics, checkPromptArtifactDeliverabilityById, resolveLivePromptInjectionProjection, runtimeBudgetObservationToResolution, selectConsoleRuntimeBudgetFacts, } from '@principles/host-runtime';
7
7
  const MVP_PROVEN_CHANNELS = new Set(MVP_CHANNELS);
8
8
  const EMPTY_STATS = { pending: 0, approved: 0, rejected: 0, cancelled: 0 };
9
9
  function isMissingTableError(err) {
@@ -14,6 +14,30 @@ function isMissingTableError(err) {
14
14
  function isActivationSuccess(activation) {
15
15
  return activation.decision === 'activated' || activation.decision === 'already_activated';
16
16
  }
17
+ /**
18
+ * PRI-1017 (SPEC §6.5 rules 1/8): name the budget basis a forecast ACTUALLY
19
+ * ran under. Every case is spelled out — silence reads as "the console is not
20
+ * bound to the runtime", and on an unconfirmed route a per-host number
21
+ * without its own basis lets one host's observation look like another host's
22
+ * confirmation.
23
+ */
24
+ function describeBudgetBasis(hostKind, facts) {
25
+ const used = facts.perHost.get(hostKind);
26
+ if (used !== undefined) {
27
+ return used.capacityStatus === 'confirmed'
28
+ ? `budget basis=runtime_observation (${hostKind}, observed at ${used.observedAt})`
29
+ // A usable observation can itself be the runtime's honest fallback (the
30
+ // host supplied no trusted capacity). The number is then the fixed
31
+ // budget, not a host window — say so.
32
+ : `budget basis=runtime_observation_fallback (${hostKind}, runtime reason=${used.fallbackReason ?? 'unknown'}, observed at ${used.observedAt})`;
33
+ }
34
+ const stale = facts.stalePerHost.get(hostKind);
35
+ if (stale !== undefined) {
36
+ return `budget basis=fixed_fallback (the runtime budget observation for ${hostKind} is stale — older than 24h and NOT used as the current budget)`;
37
+ }
38
+ // No observation at all: the number is the console's fixed fallback.
39
+ return `budget basis=fixed_fallback (no runtime budget observation for ${hostKind})`;
40
+ }
17
41
  function stateDbExists(workspaceDir) {
18
42
  return fs.existsSync(path.join(workspaceDir, '.pd', 'state.db'));
19
43
  }
@@ -252,6 +276,13 @@ export class ApprovalsConsoleModel {
252
276
  }
253
277
  if (deliverability.status === 'deliverable')
254
278
  return undefined;
279
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.3: model_dependent is NOT a
280
+ // refusal — the entry fits within the supported budget range and whether
281
+ // it injects depends on the runtime model. The approval proceeds; the
282
+ // honest uncertainty is already visible on the pre-approval badge
283
+ // (promptBudgetTierFit) and the activation status surface.
284
+ if (deliverability.status === 'model_dependent')
285
+ return undefined;
255
286
  if (deliverability.status === 'undeliverable') {
256
287
  return {
257
288
  ok: false,
@@ -276,7 +307,7 @@ export class ApprovalsConsoleModel {
276
307
  capacity: {
277
308
  category: deliverability.reason.startsWith('route_unconfirmed') ? 'route_unconfirmed' : 'content_unconfirmed',
278
309
  ...(deliverability.perHost !== undefined
279
- ? { perHost: deliverability.perHost.map((entry) => ({ hostKind: entry.hostKind, fits: entry.fits, costChars: entry.costChars })) }
310
+ ? { perHost: deliverability.perHost.map((entry) => ({ hostKind: entry.hostKind, tierFit: entry.tierFit, costChars: entry.costChars })) }
280
311
  : {}),
281
312
  },
282
313
  };
@@ -295,30 +326,55 @@ export class ApprovalsConsoleModel {
295
326
  */
296
327
  async checkPromptInjectionBudget(activationId, targetHost) {
297
328
  let resolution;
329
+ let budgetFacts;
330
+ // PRI-1017: the post-approval forecast runs under the workspace's most
331
+ // recent runtime budget OBSERVATION for the target host (per-host for
332
+ // unconfirmed routes), so the warning describes the budget the last real
333
+ // injection used — SPEC §6.1's same-budget rule. The read model degrades
334
+ // to "no observation" on unreadable logs; a projection failure below stays
335
+ // a traceable warning because the activation is already committed.
298
336
  try {
337
+ budgetFacts = selectConsoleRuntimeBudgetFacts({
338
+ workspaceDir: this.workspaceDir,
339
+ ...(targetHost !== undefined ? { targetHost } : {}),
340
+ });
341
+ const perHostBudget = new Map([...budgetFacts.perHost].map(([hostKind, observation]) => [hostKind, runtimeBudgetObservationToResolution(observation)]));
299
342
  resolution = await resolveLivePromptInjectionProjection({
300
343
  workspaceDir: this.workspaceDir,
301
- targetHost,
344
+ ...(targetHost !== undefined ? { targetHost } : {}),
345
+ ...(budgetFacts.selected !== undefined
346
+ ? { promptBudget: runtimeBudgetObservationToResolution(budgetFacts.selected) }
347
+ : {}),
348
+ ...(perHostBudget.size > 0
349
+ ? { perHostPromptBudget: Object.fromEntries(perHostBudget) }
350
+ : {}),
302
351
  });
303
352
  }
304
353
  catch (error) {
305
354
  const message = error instanceof Error ? error.message : String(error);
306
- return `injection_budget_check_failed: could not recompute the prompt injection projection (${message}); the activation is committed but its injection status is unverified. nextAction=check .pd/state.db readability and re-run pd runtime activation list`;
355
+ return `injection_budget_check_failed: could not recompute the prompt injection projection from the runtime budget observation (${message}); the activation is committed but its injection status is unverified. nextAction=check .pd/state.db and event log readability and re-run pd runtime activation list`;
307
356
  }
308
357
  if (resolution.status === 'unconfirmed') {
309
- return `injection_capacity_unconfirmed: the activation is committed but this workspace's effective injection route cannot be confirmed (${resolution.decision.unconfirmedReason ?? 'unknown reason'}); per-host forecasts: ${resolution.perHost.map((entry) => `${entry.hostKind}=${entry.projection.route} (${entry.projection.usedChars}c${entry.projection.truncated ? ', truncated' : ''})`).join('; ')}. nextAction=${resolution.decision.nextAction ?? 'pass an explicit target host (openclaw|codex|workbuddy)'}`;
358
+ // Each per-host forecast ran under ITS OWN budget basis (observation,
359
+ // runtime fallback, stale fallback, or no observation) — the headline
360
+ // numbers must never imply a single host's confirmation covers them all.
361
+ return `injection_capacity_unconfirmed: the activation is committed but this workspace's effective injection route cannot be confirmed (${resolution.decision.unconfirmedReason ?? 'unknown reason'}); per-host forecasts: ${resolution.perHost.map((entry) => `${entry.hostKind}=${entry.projection.route} (${entry.projection.usedChars}c/${entry.projection.budget}c${entry.projection.truncated ? ', truncated' : ''}) ${describeBudgetBasis(entry.hostKind, budgetFacts)}`).join('; ')}. nextAction=${resolution.decision.nextAction ?? 'pass an explicit target host (openclaw|codex|workbuddy)'}`;
310
362
  }
311
363
  const { projection } = resolution;
312
364
  if (projection.injectedActivationIds.includes(activationId)) {
313
365
  return undefined;
314
366
  }
367
+ // Never let a stale observation masquerade as the current budget in the
368
+ // warning: name the basis the forecast actually ran under.
369
+ const decidedHost = resolution.decision.hostKind;
370
+ const budgetBasisNote = decidedHost === undefined ? '' : ` ${describeBudgetBasis(decidedHost, budgetFacts)}`;
315
371
  const projectionDetail = projection.warnings.length > 0 ? ` projection warnings: ${projection.warnings.join(' | ')}` : '';
316
372
  // AC-02/R-A3: oversized is independent of `truncated` — say WHY first.
317
373
  if (projection.oversizedActivationIds.includes(activationId)) {
318
374
  const scopeNote = projection.route === 'shared_render'
319
375
  ? 'the full directive render of this single principle exceeds the cap'
320
376
  : 'its own list entry exceeds the cap';
321
- return `injection_oversized: the activation is committed but ${scopeNote} (${projection.budget} chars, measured in UTF-16 units on the ${projection.route} route) — it can never enter the prompt as written, regardless of rotation or free capacity. nextAction=shorten the statement via the activations page "modify to injectable version" entry, or deactivate this activation`;
377
+ return `injection_oversized: the activation is committed but ${scopeNote} (${projection.budget} chars, measured in UTF-16 units on the ${projection.route} route) — it can never enter the prompt as written, regardless of rotation or free capacity.${budgetBasisNote} nextAction=shorten the statement via the activations page "modify to injectable version" entry, or deactivate this activation`;
322
378
  }
323
379
  if (!projection.truncated) {
324
380
  // Not included and the budget did NOT truncate — the projection skipped
@@ -333,7 +389,7 @@ export class ApprovalsConsoleModel {
333
389
  // empty reachability set falls through to the starvation branch below.
334
390
  if (projection.productionRotates && projection.eventuallyInjectedActivationIds.includes(activationId)) {
335
391
  const window = projection.eligibleCount;
336
- return `injection_budget_queued: the activation is committed but sits outside the CURRENT prompt injection window (${projection.budget}c; ${projection.injectedActivationIds.length} of ${window} eligible activations injected this round; production selection policy fair_rotation_v1). Fair rotation advances the window by one position per recorded user turn, so this principle WILL enter agent behavior within at most ${window} consecutive user turns of a continuously advancing session — deactivating older principles is NOT required. nextAction=none; verify presence via the activations page or the prompt injection telemetry`;
392
+ return `injection_budget_queued: the activation is committed but sits outside the CURRENT prompt injection window (${projection.budget}c; ${projection.injectedActivationIds.length} of ${window} eligible activations injected this round; production selection policy fair_rotation_v1).${budgetBasisNote} Fair rotation advances the window by one position per recorded user turn, so this principle WILL enter agent behavior within at most ${window} consecutive user turns of a continuously advancing session — deactivating older principles is NOT required. nextAction=none; verify presence via the activations page or the prompt injection telemetry`;
337
393
  }
338
394
  // PR-1894: report the PRODUCTION policy, not the policy this forecast
339
395
  // happened to run. The console holds no session round key, so its own
@@ -348,7 +404,7 @@ export class ApprovalsConsoleModel {
348
404
  const unreachableNote = projection.productionRotates
349
405
  ? ' (this forecast ran without a round key, so the window shown is the legacy FIFO prefix; the entry is unreachable because it exceeds the budget on its own, not because of its position)'
350
406
  : '';
351
- return `injection_budget_excluded: the activation is committed but the prompt injection budget (${projection.budget}c) is full and this activation is not reachable under the ${policyNote}; ${projection.injectedActivationIds.length}/${projection.eligibleCount} eligible injected${unreachableNote} — this principle will NOT enter agent behavior until capacity frees up. nextAction=review the activations page and deactivate superseded principles`;
407
+ return `injection_budget_excluded: the activation is committed but the prompt injection budget (${projection.budget}c) is full and this activation is not reachable under the ${policyNote}; ${projection.injectedActivationIds.length}/${projection.eligibleCount} eligible injected${unreachableNote}.${budgetBasisNote} This principle will NOT enter agent behavior until capacity frees up. nextAction=review the activations page and deactivate superseded principles`;
352
408
  }
353
409
  /**
354
410
  * Bug-O L3b fix: upgrade the ledger principle linked to `artifactId` from
@@ -11,11 +11,23 @@ export interface ApprovalGroup {
11
11
  /**
12
12
  * PR-1894: whether this candidate's own serialized prompt entry fits the
13
13
  * injection budget on its own. `false` means the entry is oversized, so fair
14
- * rotation can NEVER inject it �?the pre-approval badge must not promise it
14
+ * rotation can NEVER inject it — the pre-approval badge must not promise it
15
15
  * will come around. Computed server-side with the production serializer
16
16
  * (the client has no access to the escaped, serialized size).
17
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: `true` now means "fits within the
18
+ * supported budget range" — see `promptBudgetTierFit` for whether that is
19
+ * unconditional (fits every tier) or model-dependent.
17
20
  */
18
21
  fitsPromptBudget?: boolean;
22
+ /**
23
+ * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §6.3: honest tier classification of
24
+ * the candidate's own serialized entry against the supported
25
+ * [min, max] budget range. `model_dependent` = fits only on
26
+ * larger-context models — the badge must qualify rotation promises, not
27
+ * promise unconditionally. `unconfirmed` mirrors the precheck's honest
28
+ * unknown.
29
+ */
30
+ promptBudgetTierFit?: 'fits_all_tiers' | 'model_dependent' | 'exceeds_max_tier' | 'unconfirmed';
19
31
  revisionReviewUnavailable?: boolean;
20
32
  revisionReview?: {
21
33
  oldStatement: string;
@@ -62,6 +74,18 @@ export declare class ApprovalsGroupedConsoleModel {
62
74
  * only: a projection failure omits the field — the approve-time precheck
63
75
  * (B1) remains the fail-loud gate (rc-9), so this must never fail the
64
76
  * grouped read.
77
+ *
78
+ * PRI-1017: the forecast now runs under the workspace's most recent
79
+ * RUNTIME BUDGET OBSERVATION (the budget the last real injection used,
80
+ * read from the existing `runtime_v2_prompt_activations_injected` events)
81
+ * instead of the console's own parameterless fallback — SPEC
82
+ * §6.1 forbids the injection and the console forecasting under different
83
+ * budgets. The observation is a PAST fact: `budgetObservedAt` travels with
84
+ * the payload so the UI labels it as such. No observation, an observation
85
+ * for a different host, or an observation past the freshness bound ⇒ the
86
+ * honest fixed-fallback projection (with `runtime_budget_observation_stale`
87
+ * when a stale observation exists). No second budget state is created:
88
+ * the events remain the single runtime record and this is a read model.
65
89
  */
66
90
  private readPromptInjectionBudgetStatus;
67
91
  dispose(): void;
@@ -1,5 +1,5 @@
1
1
  import { SqliteConnection, SqliteApprovalQueueStore, SqlitePIArtifactStore, ApprovalQueue, RUNTIME_V2_PRINCIPLE_BUDGET, detectPromptReplacementTarget, SqliteActivationStateStore, } from '@principles/core/runtime-v2';
2
- import { checkPromptArtifactDeliverability, resolveLivePromptInjectionProjection, } from '@principles/host-runtime';
2
+ import { checkPromptArtifactDeliverability, resolveLivePromptInjectionProjection, RUNTIME_BUDGET_OBSERVATION_STALE_REASON, runtimeBudgetObservationToResolution, selectConsoleRuntimeBudgetFacts, } from '@principles/host-runtime';
3
3
  import { loadLedger } from '@principles/core/principle-tree-ledger';
4
4
  import { createArtifactPrincipleResolutionDeps, resolveArtifactPrincipleId, } from './artifact-principle-resolver.js';
5
5
  import * as fs from 'node:fs';
@@ -104,19 +104,21 @@ function extractCandidateDescription(contentJson) {
104
104
  return null;
105
105
  }
106
106
  /**
107
- * PR-1894 → PD_PROMPT_CAPACITY_V1 R-A2: would this candidate's own prompt
108
- * entry fit the injection budget if it were the ONLY entry?
107
+ * PR-1894 → PD_PROMPT_CAPACITY_V1 R-A2 → SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001:
108
+ * classify this candidate's own prompt entry against the SUPPORTED budget
109
+ * range (not one fixed number — approval time cannot know the executing
110
+ * model).
109
111
  *
110
- * The judgment now comes from the ONE shared host-runtime precheck
112
+ * The judgment comes from the ONE shared host-runtime precheck
111
113
  * (`checkPromptArtifactDeliverability`): the REAL resolved principle id (not a
112
114
  * fixed 36-char UUID allowance — a title-derived id can only be longer), the
113
115
  * effective route's own serializer (list entry vs full directive render incl.
114
116
  * the self-report footer), and an honest `unconfirmed` verdict when the route
115
- * or the artifact content cannot be confirmed. `false` is also the
116
- * conservative answer for unconfirmed candidates — an unknown size must
117
+ * or the artifact content cannot be confirmed. `unconfirmed` is also the
118
+ * conservative answer when the precheck itself fails — an unknown size must
117
119
  * never become a rotation promise (rc-9).
118
120
  */
119
- async function candidateFitsPromptBudget(workspaceDir, artifact, targetHost) {
121
+ async function candidatePromptBudgetTierFit(workspaceDir, artifact, targetHost) {
120
122
  const deliverability = await checkPromptArtifactDeliverability({
121
123
  workspaceDir,
122
124
  targetHost,
@@ -130,15 +132,31 @@ async function candidateFitsPromptBudget(workspaceDir, artifact, targetHost) {
130
132
  : null,
131
133
  });
132
134
  if (deliverability.status === 'deliverable')
133
- return true;
135
+ return 'fits_all_tiers';
136
+ if (deliverability.status === 'model_dependent')
137
+ return 'model_dependent';
134
138
  if (deliverability.status === 'undeliverable')
135
- return false;
136
- // Unconfirmed: with per-host facts, only promise when EVERY applicable host
137
- // can deliver; without them, do not promise at all.
139
+ return 'exceeds_max_tier';
140
+ // Route/artifact unconfirmed: aggregate the per-host tier verdicts
141
+ // MONOTONICALLY. The list route bills ~700 fewer chars than the shared
142
+ // route for the same entry (fixed wrapper delta), so "shared exceeds but
143
+ // list does not" is REACHABLE (e.g. a ~15300-char statement: list ≈15341
144
+ // = model_dependent, shared ≈16058 = exceeds). Collapsing that to
145
+ // exceeds_max_tier would tell an OpenClaw owner their entry can never
146
+ // inject when a large-window list-route host could carry it. Only when
147
+ // EVERY applicable host exceeds is "oversized everywhere" true; any
148
+ // disagreement leaves the honest model_dependent. Without per-host
149
+ // facts, do not promise at all.
138
150
  if (deliverability.perHost && deliverability.perHost.length > 0) {
139
- return deliverability.perHost.every((entry) => entry.fits);
151
+ if (deliverability.perHost.every((entry) => entry.tierFit === 'exceeds_max_tier')) {
152
+ return 'exceeds_max_tier';
153
+ }
154
+ if (deliverability.perHost.every((entry) => entry.tierFit === 'fits_all_tiers')) {
155
+ return 'fits_all_tiers';
156
+ }
157
+ return 'model_dependent';
140
158
  }
141
- return false;
159
+ return 'unconfirmed';
142
160
  }
143
161
  export class ApprovalsGroupedConsoleModel {
144
162
  workspaceDir;
@@ -231,14 +249,14 @@ export class ApprovalsGroupedConsoleModel {
231
249
  if (!detection.ok || (detection.target !== null && !revisionReviews.has(artifact.artifactId)))
232
250
  revisionReviewUnavailable.add(artifact.artifactId);
233
251
  }
234
- artifactFitsBudgetMap.set(approval.artifactId, await candidateFitsPromptBudget(this.workspaceDir, artifact, targetHost));
252
+ artifactFitsBudgetMap.set(approval.artifactId, await candidatePromptBudgetTierFit(this.workspaceDir, artifact, targetHost));
235
253
  artifactUnavailableMap.set(approval.artifactId, artifact === null);
236
254
  }
237
255
  catch (err) {
238
256
  if (isMissingTableError(err)) {
239
257
  artifactPrincipleMap.set(approval.artifactId, null);
240
258
  artifactDescriptionMap.set(approval.artifactId, null);
241
- artifactFitsBudgetMap.set(approval.artifactId, false);
259
+ artifactFitsBudgetMap.set(approval.artifactId, 'unconfirmed');
242
260
  artifactUnavailableMap.set(approval.artifactId, true);
243
261
  }
244
262
  else {
@@ -302,10 +320,18 @@ export class ApprovalsGroupedConsoleModel {
302
320
  principleTitle,
303
321
  candidateDescription,
304
322
  status,
305
- // PR-1894: undefined-safe �?absent when the artifact could not be
323
+ // PR-1894: undefined-safe — absent when the artifact could not be
306
324
  // read, which the UI treats as "do not promise rotation".
325
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001: the boolean stays the
326
+ // rotation-promise signal (true = within the supported budget
327
+ // range); the tier fit carries the honest fits-everywhere vs
328
+ // model-dependent distinction.
307
329
  ...(firstArtifactId !== undefined && artifactFitsBudgetMap.has(firstArtifactId)
308
- ? { fitsPromptBudget: artifactFitsBudgetMap.get(firstArtifactId) === true }
330
+ ? {
331
+ fitsPromptBudget: artifactFitsBudgetMap.get(firstArtifactId) === 'fits_all_tiers'
332
+ || artifactFitsBudgetMap.get(firstArtifactId) === 'model_dependent',
333
+ promptBudgetTierFit: artifactFitsBudgetMap.get(firstArtifactId),
334
+ }
309
335
  : {}),
310
336
  // PRI-940: absent when the pinned artifact is readable — same
311
337
  // spread style as fitsPromptBudget so the wire contract stays
@@ -345,16 +371,48 @@ export class ApprovalsGroupedConsoleModel {
345
371
  * only: a projection failure omits the field — the approve-time precheck
346
372
  * (B1) remains the fail-loud gate (rc-9), so this must never fail the
347
373
  * grouped read.
374
+ *
375
+ * PRI-1017: the forecast now runs under the workspace's most recent
376
+ * RUNTIME BUDGET OBSERVATION (the budget the last real injection used,
377
+ * read from the existing `runtime_v2_prompt_activations_injected` events)
378
+ * instead of the console's own parameterless fallback — SPEC
379
+ * §6.1 forbids the injection and the console forecasting under different
380
+ * budgets. The observation is a PAST fact: `budgetObservedAt` travels with
381
+ * the payload so the UI labels it as such. No observation, an observation
382
+ * for a different host, or an observation past the freshness bound ⇒ the
383
+ * honest fixed-fallback projection (with `runtime_budget_observation_stale`
384
+ * when a stale observation exists). No second budget state is created:
385
+ * the events remain the single runtime record and this is a read model.
348
386
  */
349
387
  async readPromptInjectionBudgetStatus(targetHost) {
350
388
  try {
389
+ const facts = selectConsoleRuntimeBudgetFacts({
390
+ workspaceDir: this.workspaceDir,
391
+ ...(targetHost !== undefined ? { targetHost } : {}),
392
+ });
393
+ const perHostBudget = new Map([...facts.perHost].map(([hostKind, observation]) => [hostKind, runtimeBudgetObservationToResolution(observation)]));
351
394
  const resolution = await resolveLivePromptInjectionProjection({
352
395
  workspaceDir: this.workspaceDir,
353
- targetHost,
396
+ ...(targetHost !== undefined ? { targetHost } : {}),
397
+ ...(facts.selected !== undefined
398
+ ? { promptBudget: runtimeBudgetObservationToResolution(facts.selected) }
399
+ : {}),
400
+ ...(perHostBudget.size > 0
401
+ ? { perHostPromptBudget: Object.fromEntries(perHostBudget) }
402
+ : {}),
354
403
  });
355
404
  if (resolution.status === 'unconfirmed') {
356
405
  // The numbers still come from the flag-based view so the badge can
357
406
  // show SOMETHING, but capacityStatus marks them not authoritative.
407
+ // PRI-1017: each per-host forecast runs under THAT host's own
408
+ // observation (or its own fallback) — no host's window is smuggled
409
+ // into another host's number, and the headline reference is never
410
+ // presented as the current host's actual budget (the banner says so).
411
+ // The top-level numbers are a legacy REFERENCE view only (required
412
+ // fields for old consumers). Under `capacityStatus: 'unconfirmed'`
413
+ // they bind to no host, so the UI must never present them as the
414
+ // current host's actual budget — every host's own basis is in
415
+ // `perHost`, including its own fallback reason.
358
416
  const reference = resolution.perHost[0]?.projection;
359
417
  return {
360
418
  promptInjection: {
@@ -364,16 +422,37 @@ export class ApprovalsGroupedConsoleModel {
364
422
  capacityStatus: 'unconfirmed',
365
423
  unconfirmedReason: resolution.decision.unconfirmedReason ?? 'effective injection route cannot be confirmed',
366
424
  nextAction: resolution.decision.nextAction ?? 'pass an explicit target host (openclaw|codex|workbuddy)',
367
- perHost: resolution.perHost.map((entry) => ({
368
- hostKind: entry.hostKind,
369
- route: entry.projection.route,
370
- usedChars: entry.projection.usedChars,
371
- truncated: entry.projection.truncated,
372
- })),
425
+ perHost: resolution.perHost.map((entry) => {
426
+ const observation = facts.perHost.get(entry.hostKind);
427
+ const stale = observation === undefined ? facts.stalePerHost.get(entry.hostKind) : undefined;
428
+ return {
429
+ hostKind: entry.hostKind,
430
+ route: entry.projection.route,
431
+ usedChars: entry.projection.usedChars,
432
+ truncated: entry.projection.truncated,
433
+ budget: entry.projection.budget,
434
+ ...(entry.projection.budgetCapacitySource !== undefined ? { budgetCapacitySource: entry.projection.budgetCapacitySource } : {}),
435
+ ...(entry.projection.budgetCapacityStatus !== undefined ? { budgetCapacityStatus: entry.projection.budgetCapacityStatus } : {}),
436
+ ...(observation !== undefined ? { budgetObservedAt: observation.observedAt } : {}),
437
+ ...(stale !== undefined
438
+ ? { budgetFallbackReason: RUNTIME_BUDGET_OBSERVATION_STALE_REASON }
439
+ : entry.projection.budgetFallbackReason !== undefined
440
+ ? { budgetFallbackReason: entry.projection.budgetFallbackReason }
441
+ : {}),
442
+ };
443
+ }),
373
444
  },
374
445
  };
375
446
  }
376
447
  const { projection } = resolution;
448
+ // Bind the observation to the host the route actually resolved to — not
449
+ // to a request parameter that may be absent (single-host workspace) or
450
+ // to whichever host happens to appear first.
451
+ const decidedHost = resolution.decision.hostKind;
452
+ const usedObservation = decidedHost !== undefined ? facts.perHost.get(decidedHost) : undefined;
453
+ const staleObservation = decidedHost !== undefined && usedObservation === undefined
454
+ ? facts.stalePerHost.get(decidedHost)
455
+ : undefined;
377
456
  return {
378
457
  promptInjection: {
379
458
  budget: projection.budget,
@@ -391,6 +470,19 @@ export class ApprovalsGroupedConsoleModel {
391
470
  ...(projection.fullRenderChars !== undefined ? { fullRenderChars: projection.fullRenderChars } : {}),
392
471
  oversizedActivationIds: projection.oversizedActivationIds,
393
472
  oversizedDiagnosticTruncated: projection.oversizedDiagnosticTruncated,
473
+ // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §7: budget provenance — when
474
+ // a runtime budget OBSERVATION backs this forecast (PRI-1017) the
475
+ // numbers are the ones the last real injection used; otherwise this
476
+ // is the fixed-budget view and the UI labels it as such.
477
+ ...(projection.budgetCapacitySource !== undefined ? { budgetCapacitySource: projection.budgetCapacitySource } : {}),
478
+ ...(projection.budgetCapacityStatus !== undefined ? { budgetCapacityStatus: projection.budgetCapacityStatus } : {}),
479
+ ...(projection.budgetContextWindowTokens != null ? { budgetContextWindowTokens: projection.budgetContextWindowTokens } : {}),
480
+ ...(projection.budgetFallbackReason !== undefined ? { budgetFallbackReason: projection.budgetFallbackReason } : {}),
481
+ // A stale observation is NOT a current fact: the honest reason code
482
+ // replaces the generic absence code so the UI can say why the
483
+ // runtime's last budget was not used.
484
+ ...(staleObservation !== undefined ? { budgetFallbackReason: RUNTIME_BUDGET_OBSERVATION_STALE_REASON } : {}),
485
+ ...(usedObservation !== undefined ? { budgetObservedAt: usedObservation.observedAt } : {}),
394
486
  },
395
487
  };
396
488
  }
@@ -1,5 +1,6 @@
1
1
  import { ApprovalsGroupedConsoleModel } from '../models/ApprovalsGroupedConsoleModel.js';
2
2
  import { sendSuccess, sendError, sendNotFound, sendBadRequest } from '../utils/response.js';
3
+ import { isPromptInjectionTargetHost } from '@principles/host-runtime';
3
4
  const models = new Map();
4
5
  function getModel(workspaceDir) {
5
6
  let model = models.get(workspaceDir);
@@ -17,12 +18,14 @@ export async function handleApprovalsGroupedRoute(req, res, workspaceDir) {
17
18
  // PD_PROMPT_CAPACITY_V1 R-A1: request-level target host for the capacity
18
19
  // forecast — the request-scoped selection entry when the workspace's
19
20
  // applicable hosts would take different routes. No new persisted config.
21
+ // The roster is the host-runtime one (openclaw|codex|workbuddy) — the
22
+ // focus page offers all three, so the API must accept all three.
20
23
  const url = new URL(req.url ?? '/api/v1/approvals/grouped', 'http://localhost');
21
24
  let targetHost;
22
25
  const rawHost = url.searchParams.get('host');
23
26
  if (rawHost !== null) {
24
- if (rawHost !== 'openclaw' && rawHost !== 'codex') {
25
- sendBadRequest(res, 'host must be one of: openclaw, codex');
27
+ if (!isPromptInjectionTargetHost(rawHost)) {
28
+ sendBadRequest(res, 'host must be one of: openclaw, codex, workbuddy');
26
29
  return;
27
30
  }
28
31
  targetHost = rawHost;