create-principles-disciple 1.145.12 → 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 (33) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/codex-adapter/package.json +1 -1
  3. package/console/dist/server/models/ActivationsConsoleModel.d.ts +3 -1
  4. package/console/dist/server/models/ActivationsConsoleModel.js +38 -4
  5. package/console/dist/server/models/ApprovalsConsoleModel.js +56 -7
  6. package/console/dist/server/models/ApprovalsGroupedConsoleModel.d.ts +12 -0
  7. package/console/dist/server/models/ApprovalsGroupedConsoleModel.js +70 -13
  8. package/console/dist/server/routes/approvals-grouped.js +5 -2
  9. package/console/dist/shared/prompt-injection-contract.d.ts +58 -5
  10. package/console/dist/ui/i18n/en.json +25 -1
  11. package/console/dist/ui/i18n/zh-CN.json +25 -1
  12. package/console/dist/ui/pages/activation/ActivationPage.js +39 -1
  13. package/console/dist/ui/pages/focus/FocusPage.js +70 -28
  14. package/console/dist/ui/utils/validators.js +70 -11
  15. package/console/dist/web/assets/app.js +24 -24
  16. package/host-runtime/dist/index.d.ts +1 -0
  17. package/host-runtime/dist/index.js +4 -0
  18. package/host-runtime/dist/prompt-injection-budget-observation.d.ts +176 -0
  19. package/host-runtime/dist/prompt-injection-budget-observation.js +449 -0
  20. package/host-runtime/dist/prompt-injection-projection.d.ts +16 -0
  21. package/host-runtime/dist/prompt-injection-projection.js +18 -3
  22. package/host-runtime/dist/prompt-injection-status.d.ts +28 -1
  23. package/host-runtime/dist/prompt-injection-status.js +21 -7
  24. package/host-runtime/package.json +1 -1
  25. package/package.json +1 -1
  26. package/pd-cli/dist/commands/runtime-activation.d.ts +3 -1
  27. package/pd-cli/dist/commands/runtime-activation.d.ts.map +1 -1
  28. package/pd-cli/dist/commands/runtime-activation.js +5 -3
  29. package/pd-cli/dist/commands/runtime-activation.js.map +1 -1
  30. package/pd-cli/package.json +1 -1
  31. package/plugin/dist/bundle.js +288 -288
  32. package/release-manager/package.json +1 -1
  33. package/workbuddy-adapter/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "productVersion": "2.3.8",
4
- "sourceCommit": "0062c218a3ccffcfc3c1b79783bb147dff353648"
3
+ "productVersion": "2.3.9",
4
+ "sourceCommit": "6f9ce4614572e0ad439801a9d86fe398a0781752"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/codex-adapter",
3
- "version": "0.5.9",
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,
@@ -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
  }
@@ -302,30 +326,55 @@ export class ApprovalsConsoleModel {
302
326
  */
303
327
  async checkPromptInjectionBudget(activationId, targetHost) {
304
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.
305
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)]));
306
342
  resolution = await resolveLivePromptInjectionProjection({
307
343
  workspaceDir: this.workspaceDir,
308
- 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
+ : {}),
309
351
  });
310
352
  }
311
353
  catch (error) {
312
354
  const message = error instanceof Error ? error.message : String(error);
313
- 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`;
314
356
  }
315
357
  if (resolution.status === 'unconfirmed') {
316
- 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)'}`;
317
362
  }
318
363
  const { projection } = resolution;
319
364
  if (projection.injectedActivationIds.includes(activationId)) {
320
365
  return undefined;
321
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)}`;
322
371
  const projectionDetail = projection.warnings.length > 0 ? ` projection warnings: ${projection.warnings.join(' | ')}` : '';
323
372
  // AC-02/R-A3: oversized is independent of `truncated` — say WHY first.
324
373
  if (projection.oversizedActivationIds.includes(activationId)) {
325
374
  const scopeNote = projection.route === 'shared_render'
326
375
  ? 'the full directive render of this single principle exceeds the cap'
327
376
  : 'its own list entry exceeds the cap';
328
- 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`;
329
378
  }
330
379
  if (!projection.truncated) {
331
380
  // Not included and the budget did NOT truncate — the projection skipped
@@ -340,7 +389,7 @@ export class ApprovalsConsoleModel {
340
389
  // empty reachability set falls through to the starvation branch below.
341
390
  if (projection.productionRotates && projection.eventuallyInjectedActivationIds.includes(activationId)) {
342
391
  const window = projection.eligibleCount;
343
- 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`;
344
393
  }
345
394
  // PR-1894: report the PRODUCTION policy, not the policy this forecast
346
395
  // happened to run. The console holds no session round key, so its own
@@ -355,7 +404,7 @@ export class ApprovalsConsoleModel {
355
404
  const unreachableNote = projection.productionRotates
356
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)'
357
406
  : '';
358
- 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`;
359
408
  }
360
409
  /**
361
410
  * Bug-O L3b fix: upgrade the ledger principle linked to `artifactId` from
@@ -74,6 +74,18 @@ export declare class ApprovalsGroupedConsoleModel {
74
74
  * only: a projection failure omits the field — the approve-time precheck
75
75
  * (B1) remains the fail-loud gate (rc-9), so this must never fail the
76
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.
77
89
  */
78
90
  private readPromptInjectionBudgetStatus;
79
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';
@@ -371,16 +371,48 @@ export class ApprovalsGroupedConsoleModel {
371
371
  * only: a projection failure omits the field — the approve-time precheck
372
372
  * (B1) remains the fail-loud gate (rc-9), so this must never fail the
373
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.
374
386
  */
375
387
  async readPromptInjectionBudgetStatus(targetHost) {
376
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)]));
377
394
  const resolution = await resolveLivePromptInjectionProjection({
378
395
  workspaceDir: this.workspaceDir,
379
- 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
+ : {}),
380
403
  });
381
404
  if (resolution.status === 'unconfirmed') {
382
405
  // The numbers still come from the flag-based view so the badge can
383
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.
384
416
  const reference = resolution.perHost[0]?.projection;
385
417
  return {
386
418
  promptInjection: {
@@ -390,17 +422,37 @@ export class ApprovalsGroupedConsoleModel {
390
422
  capacityStatus: 'unconfirmed',
391
423
  unconfirmedReason: resolution.decision.unconfirmedReason ?? 'effective injection route cannot be confirmed',
392
424
  nextAction: resolution.decision.nextAction ?? 'pass an explicit target host (openclaw|codex|workbuddy)',
393
- perHost: resolution.perHost.map((entry) => ({
394
- hostKind: entry.hostKind,
395
- route: entry.projection.route,
396
- usedChars: entry.projection.usedChars,
397
- truncated: entry.projection.truncated,
398
- })),
399
- ...(reference?.budgetFallbackReason !== undefined ? { budgetFallbackReason: reference.budgetFallbackReason } : {}),
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
+ }),
400
444
  },
401
445
  };
402
446
  }
403
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;
404
456
  return {
405
457
  promptInjection: {
406
458
  budget: projection.budget,
@@ -418,14 +470,19 @@ export class ApprovalsGroupedConsoleModel {
418
470
  ...(projection.fullRenderChars !== undefined ? { fullRenderChars: projection.fullRenderChars } : {}),
419
471
  oversizedActivationIds: projection.oversizedActivationIds,
420
472
  oversizedDiagnosticTruncated: projection.oversizedDiagnosticTruncated,
421
- // SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §7: budget provenance — the
422
- // console holds no runtime model facts, so this is the fixed-budget
423
- // view by construction; the UI must label it as such rather than
424
- // presenting it as a model-accurate forecast.
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.
425
477
  ...(projection.budgetCapacitySource !== undefined ? { budgetCapacitySource: projection.budgetCapacitySource } : {}),
426
478
  ...(projection.budgetCapacityStatus !== undefined ? { budgetCapacityStatus: projection.budgetCapacityStatus } : {}),
427
479
  ...(projection.budgetContextWindowTokens != null ? { budgetContextWindowTokens: projection.budgetContextWindowTokens } : {}),
428
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 } : {}),
429
486
  },
430
487
  };
431
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;
@@ -15,11 +15,43 @@
15
15
  export type PromptInjectionTargetHostName = 'openclaw' | 'codex' | 'workbuddy';
16
16
  export type PromptInjectionRouteName = 'legacy_trim' | 'shared_render';
17
17
  export type PromptInjectionBudgetScopeName = 'selected_lines' | 'full_directive_context';
18
+ export type PromptBudgetCapacitySourceWire = 'host_effective_window' | 'pi_catalog_exact' | 'legacy_fallback';
19
+ export type PromptBudgetCapacityStatusWire = 'confirmed' | 'unknown';
20
+ /**
21
+ * Bounded machine-readable fallback reason. Closed set mirroring the core
22
+ * `PromptBudgetFallbackReason` plus the console-side observation code
23
+ * `runtime_budget_observation_stale` (PRI-1017: a runtime observation exists
24
+ * but is too old to be presented as the current budget). Never free-form
25
+ * detail or user content.
26
+ */
27
+ export type PromptBudgetFallbackReasonWire = 'host_capacity_absent' | 'host_capacity_source_untrusted' | 'host_capacity_invalid_value' | 'catalog_unavailable' | 'model_identity_absent' | 'catalog_no_exact_match' | 'catalog_no_match_for_model_id' | 'catalog_ambiguous_model_id' | 'catalog_provider_absent' | 'catalog_metadata_invalid' | 'runtime_budget_observation_stale';
18
28
  export interface PromptInjectionPerHostForecast {
19
29
  hostKind: PromptInjectionTargetHostName;
20
30
  route: PromptInjectionRouteName;
21
31
  usedChars: number;
22
32
  truncated: boolean;
33
+ /**
34
+ * PRI-1017: the budget THIS host's forecast ran under — per-host forecasts
35
+ * no longer share one headline budget (each host carries its own runtime
36
+ * budget observation or its own honest fallback).
37
+ */
38
+ budget?: number;
39
+ /** Provenance of this host's `budget` (absent = legacy fixed view). */
40
+ budgetCapacitySource?: PromptBudgetCapacitySourceWire;
41
+ /** confirmed = a real window fact; unknown = fixed fallback for this host. */
42
+ budgetCapacityStatus?: PromptBudgetCapacityStatusWire;
43
+ /**
44
+ * PRI-1017: ISO timestamp of the runtime budget observation this host's
45
+ * budget came from (absent = no observation; the fixed fallback applies).
46
+ * A PAST fact — the UI labels it as the most recent observation, never as
47
+ * a live confirmation.
48
+ */
49
+ budgetObservedAt?: string;
50
+ /**
51
+ * PRI-1017: this host's OWN fallback reason. Per-host so one host's
52
+ * missing capacity is never attributed to another host.
53
+ */
54
+ budgetFallbackReason?: PromptBudgetFallbackReasonWire;
23
55
  }
24
56
  /**
25
57
  * PD_PROMPT_CAPACITY_V1 R-A3: per-live-prompt-activation injection status on
@@ -60,6 +92,19 @@ export interface PromptInjectionRouteSummaryWire {
60
92
  nextAction?: string;
61
93
  /** Present when no status could be computed at all (fail-loud degradation). */
62
94
  globalReason?: string;
95
+ /**
96
+ * PRI-1017: ISO timestamp of the runtime budget observation the per-row
97
+ * `budget` figures were computed under (absent = fixed fallback). A PAST
98
+ * fact — label it as the most recent observation, never as live.
99
+ */
100
+ budgetObservedAt?: string;
101
+ /** PRI-1017: provenance of the budget basis behind the per-row figures. */
102
+ budgetCapacitySource?: PromptBudgetCapacitySourceWire;
103
+ budgetCapacityStatus?: PromptBudgetCapacityStatusWire;
104
+ /** Present only when the budget basis was confirmed from a real window. */
105
+ budgetContextWindowTokens?: number;
106
+ /** Present only when the rows were measured under the fixed fallback. */
107
+ budgetFallbackReason?: PromptBudgetFallbackReasonWire;
63
108
  }
64
109
  export interface PromptInjectionBudgetStatus {
65
110
  /** Hard char cap of the prompt injection surface (e.g. 2000). */
@@ -114,6 +159,15 @@ export interface PromptInjectionBudgetStatus {
114
159
  nextAction?: string;
115
160
  /** Present when capacityStatus === 'unconfirmed': per-host forecasts. */
116
161
  perHost?: PromptInjectionPerHostForecast[];
162
+ /**
163
+ * PRI-1017: ISO timestamp of the runtime budget OBSERVATION `budget` and
164
+ * the provenance fields came from (absent = no usable observation; the
165
+ * numbers are the console's own fixed-fallback projection). The console
166
+ * holds no live host facts: an observation is the budget the LAST real
167
+ * injection ran under, and the UI must say so — never present it as a
168
+ * live confirmation.
169
+ */
170
+ budgetObservedAt?: string;
117
171
  /**
118
172
  * SPEC-PD-MODEL-AWARE-PROMPT-BUDGET-001 §7: provenance of `budget` — the
119
173
  * same resolution the real injection runs. `legacy_fallback` + `unknown`
@@ -122,15 +176,14 @@ export interface PromptInjectionBudgetStatus {
122
176
  * model-accurate forecast. Absent behaves as the legacy fixed view
123
177
  * (pre-SPEC payload compat).
124
178
  */
125
- budgetCapacitySource?: 'host_effective_window' | 'pi_catalog_exact' | 'legacy_fallback';
179
+ budgetCapacitySource?: PromptBudgetCapacitySourceWire;
126
180
  /** confirmed = a real context window was resolved; unknown = fixed-budget fallback. */
127
- budgetCapacityStatus?: 'confirmed' | 'unknown';
181
+ budgetCapacityStatus?: PromptBudgetCapacityStatusWire;
128
182
  /** The context window the budget was derived from; present only when confirmed. */
129
183
  budgetContextWindowTokens?: number;
130
184
  /**
131
185
  * SPEC §7 v0.2: bounded machine-readable reason — present only when
132
- * budgetCapacityStatus is 'unknown'. Closed set mirroring the core
133
- * `PromptBudgetFallbackReason`; never free-form detail or user content.
186
+ * budgetCapacityStatus is 'unknown'. See `PromptBudgetFallbackReasonWire`.
134
187
  */
135
- budgetFallbackReason?: 'host_capacity_absent' | 'host_capacity_source_untrusted' | 'host_capacity_invalid_value' | 'catalog_unavailable' | 'model_identity_absent' | 'catalog_no_exact_match' | 'catalog_no_match_for_model_id' | 'catalog_ambiguous_model_id' | 'catalog_provider_absent' | 'catalog_metadata_invalid';
188
+ budgetFallbackReason?: PromptBudgetFallbackReasonWire;
136
189
  }
@@ -100,13 +100,35 @@
100
100
  "promptCapacityUnconfirmed": "Host capacity unconfirmed: the applicable hosts of this workspace take different injection routes; the numbers below are not bound to a specific host.",
101
101
  "promptCapacitySharedRoute": "shared injection (billed on the full injected block)",
102
102
  "promptCapacityListRoute": "list injection (billed on the selected lines)",
103
- "promptCapacityPerHostEntry": "{{host}}: {{billing}} — {{used}}/{{budget}} chars{{full}}",
103
+ "promptCapacityPerHostEntry": "{{host}}: {{billing}} — {{used}}/{{budget}} chars{{full}}{{observed}}",
104
104
  "promptCapacityPerHostFull": " (full)",
105
+ "promptCapacityPerHostObserved": " (budget from this host's most recent runtime observation at {{observedAt}})",
105
106
  "promptCapacityViewAsHost": "View as {{host}}",
106
107
  "promptCapacityClearHost": "Clear selection",
107
108
  "promptScopeShared": "Forecast window (shared route, billed on the full injected block, unit=chars)",
108
109
  "promptScopeList": "Forecast window (list route, billed on the selected lines{{full}}, unit=chars)",
109
110
  "promptScopeFullRender": "; full injected block ≈ {{count}} chars",
111
+ "promptBudgetModelAwareNote": "Budget resolved from the confirmed model context ({{window}} tokens)",
112
+ "promptBudgetObservedNote": "Budget from the most recent runtime observation (source {{source}}, context {{window}} tokens, observed at {{observedAt}})",
113
+ "promptBudgetFallbackNote": "Estimated with the fixed fallback budget (console not bound to a runtime model{{reason}})",
114
+ "promptBudgetFallbackObservedNote": "The runtime's most recent observation did not confirm host capacity; estimated with the fixed fallback budget ({{reason}}, observed at {{observedAt}})",
115
+ "promptBudgetFallbackReasonSuffix": ", reason: {{code}}",
116
+ "promptBudgetFallbackObservedSuffix": "; the runtime's most recent observation was at {{observedAt}}",
117
+ "budgetCapacitySourceHostWindow": "host effective context window",
118
+ "budgetCapacitySourcePiCatalog": "exact model catalog match",
119
+ "budgetFallbackReason": {
120
+ "host_capacity_absent": "the host provided no context capacity fact",
121
+ "host_capacity_source_untrusted": "the host capacity source is untrusted",
122
+ "host_capacity_invalid_value": "the host capacity value is invalid",
123
+ "catalog_unavailable": "the model catalog is unavailable",
124
+ "model_identity_absent": "model identity is missing",
125
+ "catalog_no_exact_match": "no exact match in the model catalog",
126
+ "catalog_no_match_for_model_id": "the model id is not in the model catalog",
127
+ "catalog_ambiguous_model_id": "the model id is ambiguous in the model catalog",
128
+ "catalog_provider_absent": "the provider id is missing",
129
+ "catalog_metadata_invalid": "the model catalog metadata is invalid",
130
+ "runtime_budget_observation_stale": "the runtime budget observation is stale (older than 24h; not used as the current budget)"
131
+ },
110
132
  "artifactUnavailableNote": "The draft artifact this approval points to is no longer in the artifact store (likely superseded by a newer revision), so its content cannot be shown. If in doubt, reject it; the raw artifact ID is shown in the replacement section's current-artifact field.",
111
133
  "unlinkedCandidateNote": "No principle name is available for this card: its lineage cannot be resolved to a principle, and no readable title could be extracted from its stored content. Judge it from the approval records below; if you cannot decide, you can reject it.",
112
134
  "approveAction": "Approve",
@@ -1031,6 +1053,8 @@
1031
1053
  "tagHighRisk": "High Risk",
1032
1054
  "tagNeverActivated": "Never Activated",
1033
1055
  "injectionLabel": "Injection status",
1056
+ "budgetObservedNote": "The budget figures in the injection status come from the most recent runtime observation (source {{source}}, context {{window}} tokens, observed at {{observedAt}}), not a live confirmation.",
1057
+ "budgetFallbackNote": "The injection status is estimated with the fixed fallback budget ({{reason}}{{observed}}).",
1034
1058
  "injectionInjectable": "Injectable (in current window, {{cost}}/{{budget}} chars)",
1035
1059
  "injectionWindowRotates": "Out of window this turn: fits alone ({{cost}}/{{budget}} chars); rotation will reach it in later turns (forecast)",
1036
1060
  "injectionWindowFifo": "Out of window this turn: fits alone ({{cost}}/{{budget}} chars); this route does not rotate, capacity must free up first",
@@ -100,13 +100,35 @@
100
100
  "promptCapacityUnconfirmed": "当前宿主容量未确认:本工作区适用的宿主会走不同的注入路由,以下数字未绑定具体宿主。",
101
101
  "promptCapacitySharedRoute": "共享注入(按完整注入块计费)",
102
102
  "promptCapacityListRoute": "列表注入(按选中行计费)",
103
- "promptCapacityPerHostEntry": "{{host}}:{{billing}} — {{used}}/{{budget}} 字符{{full}}",
103
+ "promptCapacityPerHostEntry": "{{host}}:{{billing}} — {{used}}/{{budget}} 字符{{full}}{{observed}}",
104
104
  "promptCapacityPerHostFull": "(已满)",
105
+ "promptCapacityPerHostObserved": "(预算来自该宿主最近一次运行时观测,观测于 {{observedAt}})",
105
106
  "promptCapacityViewAsHost": "按 {{host}} 查看",
106
107
  "promptCapacityClearHost": "清除选择",
107
108
  "promptScopeShared": "预测窗口(共享注入路由,按完整注入块计费,单位=字符)",
108
109
  "promptScopeList": "预测窗口(列表注入路由,按选中行计费{{full}},单位=字符)",
109
110
  "promptScopeFullRender": ",完整注入块约 {{count}} 字符",
111
+ "promptBudgetModelAwareNote": "预算按已确认的模型上下文解析({{window}} tokens)",
112
+ "promptBudgetObservedNote": "预算取自最近一次运行时观测(来源 {{source}},上下文 {{window}} tokens,观测于 {{observedAt}})",
113
+ "promptBudgetFallbackNote": "按固定回退预算估算(控制台未绑定运行时模型{{reason}})",
114
+ "promptBudgetFallbackObservedNote": "运行时最近一次观测未确认宿主容量,预算按固定回退估算({{reason}},观测于 {{observedAt}})",
115
+ "promptBudgetFallbackReasonSuffix": ",原因: {{code}}",
116
+ "promptBudgetFallbackObservedSuffix": ";运行时最近一次观测于 {{observedAt}}",
117
+ "budgetCapacitySourceHostWindow": "宿主有效上下文窗口",
118
+ "budgetCapacitySourcePiCatalog": "模型目录精确匹配",
119
+ "budgetFallbackReason": {
120
+ "host_capacity_absent": "宿主未提供上下文容量事实",
121
+ "host_capacity_source_untrusted": "宿主容量来源不可信",
122
+ "host_capacity_invalid_value": "宿主容量值无效",
123
+ "catalog_unavailable": "模型目录不可用",
124
+ "model_identity_absent": "缺少模型身份",
125
+ "catalog_no_exact_match": "模型目录无精确匹配",
126
+ "catalog_no_match_for_model_id": "模型目录中无此模型 ID",
127
+ "catalog_ambiguous_model_id": "模型 ID 在目录中存在歧义",
128
+ "catalog_provider_absent": "缺少提供商标识",
129
+ "catalog_metadata_invalid": "模型目录元数据无效",
130
+ "runtime_budget_observation_stale": "运行时预算观测已过期(超过 24 小时,不作为当前预算依据)"
131
+ },
110
132
  "artifactUnavailableNote": "该审批指向的草稿原件已不在工件库中(可能已被新版本取代),内容无法显示。如无法判断,可选择拒绝;原始工件编号可点「替换待审版本」后在「当前工件」一栏查看。",
111
133
  "unlinkedCandidateNote": "这张卡片没有可展示的原则名称:无法把它的血缘解析到某条原则,也没能从其存档内容里提取出可读的标题。请依据下方的审批记录判断;如无法判断,可选择拒绝。",
112
134
  "approveAction": "批准",
@@ -1031,6 +1053,8 @@
1031
1053
  "tagHighRisk": "高风险",
1032
1054
  "tagNeverActivated": "从未激活",
1033
1055
  "injectionLabel": "注入状态",
1056
+ "budgetObservedNote": "注入状态中的预算数字取自最近一次运行时观测(来源 {{source}},上下文 {{window}} tokens,观测于 {{observedAt}}),并非实时确认值。",
1057
+ "budgetFallbackNote": "注入状态按固定回退预算估算({{reason}}{{observed}})。",
1034
1058
  "injectionInjectable": "可注入(当前窗口内,{{cost}}/{{budget}} 字符)",
1035
1059
  "injectionWindowRotates": "本轮未入窗口:单条可装入({{cost}}/{{budget}} 字符),后续轮次会轮到(预测)",
1036
1060
  "injectionWindowFifo": "本轮未入窗口:单条可装入({{cost}}/{{budget}} 字符),当前路由不轮转,需腾出容量才会注入",