@principles/host-runtime 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Internalization consumer governance wiring (host-neutral, PRI-624).
3
+ *
4
+ * Ported verbatim from openclaw-plugin `auto-consumer-governance-wiring.ts`
5
+ * (P0-D/E/F 生产接线) so the OpenClaw auto-consumer and the Companion
6
+ * workspace worker share ONE wiring implementation instead of copying it:
7
+ * - evaluator: isRepairLoopEnabled + seedArtificerRepairTask (bounded repair)
8
+ * - rollout_reviewer: dispatchActivation (approve_rollout → ActivationDispatcher,
9
+ * 低风险 auto_activate / 高风险 approvals.pending) + reopenRevisionTarget
10
+ * (needs_revision → reopen scribe/artificer, 禁止入 approval)
11
+ *
12
+ * Everything is assembled from @principles/core stores/dispatchers; the only
13
+ * host knowledge is a structural logger. Idempotency (INV-08): dispatcher
14
+ * dedupes by `${artifactId}::${channel}`; reopen idempotency is owned by
15
+ * orchestrator.reopenTaskForRevision.
16
+ */
17
+ import { ActivationDispatcher, PromptWriter, DeferArchiveWriter, RuleHostWriter, SqliteConnection, SqliteActivationStateStore, SqliteApprovalQueueStore, SqlitePIArtifactStore, createProductionGateDeps, createPITaskDiagnosticJson, computeFeatureFlagsFromConfig, isFeatureEnabled, } from '@principles/core/runtime-v2';
18
+ import { loadPdConfigForPlugin } from './pd-config.js';
19
+ function normalizeDecision(decision) {
20
+ if (decision.decision === 'activated') {
21
+ return { decision: decision.decision, activationId: decision.activationId };
22
+ }
23
+ if (decision.decision === 'already_activated') {
24
+ return { decision: decision.decision, reason: 'idempotent_redispatch' };
25
+ }
26
+ if (decision.decision === 'queued_for_approval') {
27
+ return { decision: decision.decision, reason: decision.approvalId };
28
+ }
29
+ // refused / would_* 家族
30
+ const reason = 'reason' in decision && typeof decision.reason === 'string' ? decision.reason : decision.decision;
31
+ return { decision: decision.decision, reason };
32
+ }
33
+ function makeFlagProbe(workspaceDir) {
34
+ const configResult = loadPdConfigForPlugin(workspaceDir);
35
+ if (!configResult.ok)
36
+ return () => false;
37
+ const flags = computeFeatureFlagsFromConfig(configResult.effective);
38
+ return (flagId) => isFeatureEnabled(flags, flagId);
39
+ }
40
+ /** rollout → ActivationDispatcher 的生产接线 (per dispatch 打开短连接, 与 Console 模式一致) */
41
+ export async function dispatchRolloutActivation(workspaceDir, input, logger) {
42
+ const connection = new SqliteConnection(workspaceDir);
43
+ try {
44
+ const piArtifactStore = new SqlitePIArtifactStore(connection);
45
+ const artifactReadModel = {
46
+ getArtifactById: async (id) => {
47
+ const rec = await piArtifactStore.getArtifactById(id);
48
+ if (!rec)
49
+ return null;
50
+ return {
51
+ artifactId: rec.artifactId,
52
+ artifactKind: rec.artifactKind,
53
+ sourceTaskId: rec.sourceTaskId,
54
+ sourcePrincipleId: rec.sourcePrincipleId,
55
+ sourceRuleId: rec.sourceRuleId,
56
+ lineageArtifactIds: rec.lineageArtifactIds,
57
+ validationStatus: rec.validationStatus,
58
+ contentJson: rec.contentJson,
59
+ createdAt: rec.createdAt,
60
+ updatedAt: rec.updatedAt,
61
+ };
62
+ },
63
+ };
64
+ const activationStateStore = new SqliteActivationStateStore(connection);
65
+ const approvalQueueStore = new SqliteApprovalQueueStore(connection);
66
+ const flagProbe = makeFlagProbe(workspaceDir);
67
+ const dispatcher = new ActivationDispatcher(artifactReadModel, activationStateStore, {
68
+ writers: [
69
+ new PromptWriter(),
70
+ new RuleHostWriter({ gateDeps: createProductionGateDeps(), featureFlagProbe: flagProbe }),
71
+ new DeferArchiveWriter(),
72
+ ],
73
+ approvalQueueStore,
74
+ });
75
+ const decision = await dispatcher.dispatch({
76
+ artifactId: input.artifactId,
77
+ channel: input.channel, // InternalizationChannel union; rollout 链的 channel 已由任务元数据校验
78
+ rolloutDecision: 'auto_activate',
79
+ actor: { kind: 'system', source: 'rollout_reviewer' },
80
+ now: new Date().toISOString(),
81
+ confirm: true,
82
+ confidence: input.confidence,
83
+ });
84
+ const outcome = normalizeDecision(decision);
85
+ logger?.info?.(`[PD:Consumer] rollout dispatch: artifact=${input.artifactId} channel=${input.channel} → ${outcome.decision}${outcome.activationId ? ` (${outcome.activationId})` : ''}${outcome.reason ? ` reason=${outcome.reason}` : ''}`);
86
+ return outcome;
87
+ }
88
+ finally {
89
+ try {
90
+ connection.close();
91
+ }
92
+ catch { /* best-effort */ }
93
+ }
94
+ }
95
+ /** flag 探针: config 异常 → 全 false (fail-closed, 不 throw) */
96
+ /** evaluator repair deps (PRI-509 机制的生产接线; flag 语义保留为运行时开关) */
97
+ export function createEvaluatorRepairDeps(workspaceDir, stateManager, logger) {
98
+ return {
99
+ isRepairLoopEnabled: () => {
100
+ // flag evaluator_artificer_repair_loop (registry 默认已改为 ON — 见
101
+ // feature-flag-contract.ts;此处读取 config 以保留运行时可关闭能力)
102
+ const configResult = loadPdConfigForPlugin(workspaceDir);
103
+ if (!configResult.ok)
104
+ return false;
105
+ const flags = computeFeatureFlagsFromConfig(configResult.effective);
106
+ return isFeatureEnabled(flags, 'evaluator_artificer_repair_loop');
107
+ },
108
+ seedArtificerRepairTask: async (params) => {
109
+ // P0-4: 确定性 revision identity — evaluatorTaskId + iteration 唯一定位
110
+ // 一个逻辑 repair 任务; 重放 (consumer 重复周期 / crash 恢复) reuse 而非再建。
111
+ const repairTaskId = `artificer-repair-${params.repairPayload.sourceEvaluatorTaskId}-r${params.repairPayload.repairIteration}`;
112
+ const existing = await stateManager.getTask(repairTaskId);
113
+ if (existing) {
114
+ logger?.info?.(`[PD:Consumer] repair task ${repairTaskId} already exists; reusing (idempotent seed)`);
115
+ return repairTaskId;
116
+ }
117
+ await stateManager.createTask({
118
+ taskId: repairTaskId,
119
+ taskKind: 'artificer',
120
+ status: 'pending',
121
+ attemptCount: 0,
122
+ maxAttempts: 3,
123
+ diagnosticJson: createPITaskDiagnosticJson({
124
+ dependencyTaskIds: [...params.inheritedDependencyTaskIds],
125
+ channel: params.inheritedChannel,
126
+ timeoutMs: params.inheritedTimeoutMs,
127
+ inputArtifactRefs: [...params.inheritedInputArtifactRefs],
128
+ outputArtifactRefs: [],
129
+ repairPayload: params.repairPayload,
130
+ }),
131
+ });
132
+ logger?.info?.(`[PD:Consumer] seeded artificer repair task ${repairTaskId} (iteration ${params.repairPayload.repairIteration})`);
133
+ return repairTaskId;
134
+ },
135
+ };
136
+ }
137
+ /** rollout reviewer 治理 deps: dispatch + revision reopen */
138
+ export function createRolloutGovernanceDeps(workspaceDir, orchestrator, logger) {
139
+ return {
140
+ dispatchActivation: (input) => dispatchRolloutActivation(workspaceDir, input, logger),
141
+ reopenRevisionTarget: async (input) => {
142
+ const result = await orchestrator.reopenTaskForRevision(input.targetTaskId, {
143
+ revisionFeedback: input.revisionFeedback,
144
+ reason: `rollout_revision_iteration_${input.revisionIteration}`,
145
+ revisionCauseId: `rollout-${input.sourceRolloutTaskId}-r${input.revisionIteration}`,
146
+ });
147
+ return result.ok
148
+ ? { ok: true, reason: result.reason, reopenedTaskId: input.targetTaskId }
149
+ : { ok: false, reason: result.reason };
150
+ },
151
+ };
152
+ }
@@ -1,5 +1,8 @@
1
1
  import Database from 'better-sqlite3';
2
+ import type { GovernanceHostKind } from '@principles/core/runtime-v2';
2
3
  import type { HostEvent, HostEventResult } from '@principles/core/host';
4
+ /** Shared with the governance admission path so both gate tool failures identically. */
5
+ export declare const PRODUCTION_WRITE_TOOLS: ReadonlySet<string>;
3
6
  export interface PainEvidenceEntry {
4
7
  sourceRef: string;
5
8
  note: string;
@@ -16,9 +19,73 @@ export interface ProductionPainEnrichment {
16
19
  }
17
20
  export type PainEnrichmentProvider = (event: HostEvent) => unknown | Promise<unknown>;
18
21
  export type PainDatabaseFactory = (databasePath: string) => Database.Database;
22
+ interface NormalizedOutcome {
23
+ failure: boolean;
24
+ exitCode: number;
25
+ error?: string;
26
+ durationMs?: number;
27
+ params: unknown;
28
+ result: unknown;
29
+ }
30
+ /** Structural input accepted by the canonical derivations (HostEvent satisfies it without a cast). */
31
+ export interface ProductionToolEventFields {
32
+ workspaceDir: string;
33
+ sessionId: string;
34
+ turnId?: string;
35
+ toolName?: string;
36
+ toolInput?: unknown;
37
+ toolOutput?: unknown;
38
+ source: string;
39
+ }
40
+ export interface DerivedToolPainIdentity {
41
+ eventId: string;
42
+ painId: string;
43
+ outcome: NormalizedOutcome;
44
+ sanitizedParams: Record<string, unknown>;
45
+ paramsJson: string;
46
+ resultPreview: string | null;
47
+ }
48
+ /**
49
+ * The canonical tool-pain identity derivation, exposed for the governance
50
+ * admission path (Codex Governance Closure SPEC §10): the same normalized
51
+ * fields fed to the live production handler derive the same deterministic
52
+ * `pain_host_<sha256>` id, so live and observation-delivered admissions of one
53
+ * tool call converge on one canonical pain.
54
+ */
55
+ export declare function deriveProductionToolPainIdentity(fields: ProductionToolEventFields & {
56
+ canonicalEventId?: string;
57
+ }): DerivedToolPainIdentity;
58
+ /**
59
+ * The canonical correction-pain identity derivation (SPEC §10/§12): deterministic,
60
+ * content-derived, retry-safe — replacing the legacy random `correction_<traceId>`
61
+ * ids. The identity is scoped by a stable OCCURRENCE identity supplied by the
62
+ * host (Codex hostTurnId / OpenClaw per-session turn index), so:
63
+ * - a retry / live+transcript replay of the SAME real occurrence → same pain;
64
+ * - the same correction text in a LATER real turn → a NEW pain occurrence.
65
+ * The raw text participates only in the in-memory hash — it is never persisted
66
+ * (the persistence boundary sanitizes it; see governance-signal-admission).
67
+ */
68
+ export declare function deriveProductionCorrectionPainIdentity(fields: {
69
+ workspaceDir: string;
70
+ sessionId: string;
71
+ /** Stable occurrence identity of the real correction event (host turn id / per-session turn index). */
72
+ occurrenceId: string;
73
+ text: string;
74
+ }): {
75
+ eventId: string;
76
+ painId: string;
77
+ };
78
+ declare function hasCanonicalSchema(db: Database.Database): boolean;
79
+ /**
80
+ * PRI-640: host attribution is supplied by the host adapter that constructs
81
+ * this shared handler (OpenClaw / Codex adapters) — never guessed inside the
82
+ * writer. Omitted -> NULL (unknown).
83
+ */
19
84
  export declare function createProductionPainEvidenceHandler(options?: {
20
85
  painEnrichmentProvider?: PainEnrichmentProvider;
21
86
  painDatabaseFactory?: PainDatabaseFactory;
87
+ hostKind?: GovernanceHostKind;
22
88
  }): (event: HostEvent) => Promise<HostEventResult>;
23
89
  export declare function resetProductionPainCooldownForTest(): void;
24
90
  export declare function productionPainCooldownEntryCountForTest(): number;
91
+ export { hasCanonicalSchema as hasProductionPainSchema };
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, sanitizeToolParams, sanitizeValue, } from '@principles/core/runtime-v2';
6
6
  const WRITE_TOOLS = new Set(['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace']);
7
+ /** Shared with the governance admission path so both gate tool failures identically. */
8
+ export const PRODUCTION_WRITE_TOOLS = WRITE_TOOLS;
7
9
  const MAX_PREVIEW = 500;
8
10
  const PAIN_COOLDOWN_WINDOW_MS = 15 * 60 * 1000;
9
11
  const cooldowns = new Map();
@@ -16,7 +18,8 @@ function field(value, key) {
16
18
  return Object.getOwnPropertyDescriptor(value, key)?.value;
17
19
  }
18
20
  function normalizeOutcome(event) {
19
- const envelope = event.context.toolOutput;
21
+ const context = 'context' in event ? event.context : event;
22
+ const envelope = context.toolOutput;
20
23
  const result = field(envelope, 'result') ?? envelope;
21
24
  const errorValue = field(envelope, 'error');
22
25
  const resultExit = field(result, 'exitCode');
@@ -29,7 +32,7 @@ function normalizeOutcome(event) {
29
32
  exitCode,
30
33
  ...(error ? { error: error.slice(0, MAX_PREVIEW) } : {}),
31
34
  ...(typeof durationValue === 'number' && Number.isFinite(durationValue) && durationValue >= 0 ? { durationMs: durationValue } : {}),
32
- params: event.context.toolInput ?? {},
35
+ params: context.toolInput ?? {},
33
36
  result,
34
37
  };
35
38
  }
@@ -100,16 +103,16 @@ function preview(value) {
100
103
  }
101
104
  }
102
105
  function ids(input) {
103
- const { event, outcome, sanitizedParams, canonicalEventId } = input;
106
+ const { outcome, sanitizedParams, canonicalEventId } = input;
104
107
  const canonical = stable({
105
- workspaceDir: path.resolve(event.context.workspaceDir),
106
- sessionId: event.context.sessionId,
107
- turnId: event.context.turnId ?? null,
108
- toolName: event.context.toolName,
109
- source: event.source,
108
+ workspaceDir: path.resolve(input.workspaceDir),
109
+ sessionId: input.sessionId,
110
+ turnId: input.turnId ?? null,
111
+ toolName: input.toolName,
112
+ source: input.source,
110
113
  suppliedEventId: canonicalEventId ?? null,
111
114
  params: sanitizedParams,
112
- result: sanitizeValue(outcome.result, 0, event.context.workspaceDir),
115
+ result: sanitizeValue(outcome.result, 0, input.workspaceDir),
113
116
  error: outcome.error ?? null,
114
117
  exitCode: outcome.exitCode,
115
118
  failure: outcome.failure,
@@ -117,10 +120,60 @@ function ids(input) {
117
120
  const digest = createHash('sha256').update(canonical).digest('hex');
118
121
  return { eventId: `host_${digest}`, painId: `pain_host_${digest}` };
119
122
  }
123
+ /**
124
+ * The canonical tool-pain identity derivation, exposed for the governance
125
+ * admission path (Codex Governance Closure SPEC §10): the same normalized
126
+ * fields fed to the live production handler derive the same deterministic
127
+ * `pain_host_<sha256>` id, so live and observation-delivered admissions of one
128
+ * tool call converge on one canonical pain.
129
+ */
130
+ export function deriveProductionToolPainIdentity(fields) {
131
+ const outcome = normalizeOutcome(fields);
132
+ const sanitizedParams = sanitizeToolParams(outcome.params, fields.workspaceDir);
133
+ const { eventId, painId } = ids({
134
+ workspaceDir: fields.workspaceDir,
135
+ sessionId: fields.sessionId,
136
+ ...(fields.turnId !== undefined ? { turnId: fields.turnId } : {}),
137
+ ...(fields.toolName !== undefined ? { toolName: fields.toolName } : {}),
138
+ source: fields.source,
139
+ ...(fields.canonicalEventId !== undefined ? { canonicalEventId: fields.canonicalEventId } : {}),
140
+ outcome,
141
+ sanitizedParams,
142
+ });
143
+ return {
144
+ eventId,
145
+ painId,
146
+ outcome,
147
+ sanitizedParams,
148
+ paramsJson: stable(sanitizedParams),
149
+ resultPreview: preview({ eventId, result: sanitizeValue(outcome.result, 0, fields.workspaceDir) }),
150
+ };
151
+ }
152
+ /**
153
+ * The canonical correction-pain identity derivation (SPEC §10/§12): deterministic,
154
+ * content-derived, retry-safe — replacing the legacy random `correction_<traceId>`
155
+ * ids. The identity is scoped by a stable OCCURRENCE identity supplied by the
156
+ * host (Codex hostTurnId / OpenClaw per-session turn index), so:
157
+ * - a retry / live+transcript replay of the SAME real occurrence → same pain;
158
+ * - the same correction text in a LATER real turn → a NEW pain occurrence.
159
+ * The raw text participates only in the in-memory hash — it is never persisted
160
+ * (the persistence boundary sanitizes it; see governance-signal-admission).
161
+ */
162
+ export function deriveProductionCorrectionPainIdentity(fields) {
163
+ const canonical = stable({
164
+ workspaceDir: path.resolve(fields.workspaceDir),
165
+ sessionId: fields.sessionId,
166
+ occurrenceId: fields.occurrenceId,
167
+ source: 'user_correction',
168
+ text: fields.text,
169
+ });
170
+ const digest = createHash('sha256').update(canonical).digest('hex');
171
+ return { eventId: `host_${digest}`, painId: `pain_host_${digest}` };
172
+ }
120
173
  const REQUIRED_COLUMNS = {
121
174
  sessions: { session_id: 'TEXT', started_at: 'TEXT', updated_at: 'TEXT' },
122
175
  tool_calls: { session_id: 'TEXT', tool_name: 'TEXT', outcome: 'TEXT', duration_ms: 'INTEGER', exit_code: 'INTEGER', error_type: 'TEXT', error_message: 'TEXT', gfi_before: 'REAL', gfi_after: 'REAL', params_json: 'TEXT', result_preview: 'TEXT', created_at: 'TEXT' },
123
- pain_events: { session_id: 'TEXT', source: 'TEXT', score: 'REAL', reason: 'TEXT', severity: 'TEXT', origin: 'TEXT', confidence: 'REAL', text: 'TEXT', canonical_pain_id: 'TEXT', runtime_task_id: 'TEXT', created_at: 'TEXT' },
176
+ pain_events: { session_id: 'TEXT', source: 'TEXT', score: 'REAL', reason: 'TEXT', severity: 'TEXT', origin: 'TEXT', confidence: 'REAL', text: 'TEXT', canonical_pain_id: 'TEXT', runtime_task_id: 'TEXT', host_kind: 'TEXT', created_at: 'TEXT' },
124
177
  };
125
178
  function pragmaField(row, key) {
126
179
  return isRecord(row) && Object.hasOwn(row, key) ? Object.getOwnPropertyDescriptor(row, key)?.value : undefined;
@@ -171,9 +224,14 @@ function hasCanonicalSchema(db) {
171
224
  db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?');
172
225
  db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at');
173
226
  db.prepare('INSERT INTO tool_calls (session_id, tool_name, outcome, duration_ms, exit_code, error_type, error_message, gfi_before, gfi_after, params_json, result_preview, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
174
- db.prepare('INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
227
+ db.prepare('INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, host_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
175
228
  return true;
176
229
  }
230
+ /**
231
+ * PRI-640: host attribution is supplied by the host adapter that constructs
232
+ * this shared handler (OpenClaw / Codex adapters) — never guessed inside the
233
+ * writer. Omitted -> NULL (unknown).
234
+ */
177
235
  export function createProductionPainEvidenceHandler(options = {}) {
178
236
  return async (event) => {
179
237
  const dbPath = path.join(event.context.workspaceDir, '.state', 'trajectory.db');
@@ -232,7 +290,16 @@ export function createProductionPainEvidenceHandler(options = {}) {
232
290
  const triage = evaluateTriage({ sourceKind, score: painScore, consecutiveErrors: enrichment.consecutiveErrors, isRisky });
233
291
  const trigger = evaluateTriggerController({ triageResult: triage, isOwnerManual: false, isCooldownActive: cooldownActive, isValid: true, score: painScore, sessionId: event.context.sessionId });
234
292
  const admitted = outcome.failure && WRITE_TOOLS.has(toolName) && trigger.shouldCreateDiagnosticTask;
235
- const { eventId, painId } = ids({ event, outcome, sanitizedParams, ...(enrichment.eventId ? { canonicalEventId: enrichment.eventId } : {}) });
293
+ const { eventId, painId } = ids({
294
+ workspaceDir: event.context.workspaceDir,
295
+ sessionId: event.context.sessionId,
296
+ ...(event.context.turnId !== undefined ? { turnId: event.context.turnId } : {}),
297
+ ...(toolName !== undefined ? { toolName } : {}),
298
+ source: event.source,
299
+ ...(enrichment.eventId ? { canonicalEventId: enrichment.eventId } : {}),
300
+ outcome,
301
+ sanitizedParams,
302
+ });
236
303
  const createdAt = new Date().toISOString();
237
304
  const paramsJson = stable(sanitizedParams);
238
305
  const resultPreview = preview({ eventId, result: sanitizeValue(outcome.result, 0, event.context.workspaceDir) });
@@ -257,8 +324,8 @@ export function createProductionPainEvidenceHandler(options = {}) {
257
324
  // declared enum (event-types.ts: who reported the pain), not an
258
325
  // attribution claim.
259
326
  const reason = `tool=${toolName}; error=${outcome.error ?? `exit=${outcome.exitCode}`}; path=${relativePath}`;
260
- db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
261
- .run(event.context.sessionId, sourceObservation.failureSource ?? 'tool_failure', painScore, reason, painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild', 'system_infer', null, enrichment.evidence?.map((entry) => `${entry.sourceRef}: ${entry.note}`).join('\n') ?? null, painId, null, createdAt);
327
+ db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, host_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
328
+ .run(event.context.sessionId, sourceObservation.failureSource ?? 'tool_failure', painScore, reason, painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild', 'system_infer', null, enrichment.evidence?.map((entry) => `${entry.sourceRef}: ${entry.note}`).join('\n') ?? null, painId, null, options.hostKind ?? null, createdAt);
262
329
  }
263
330
  })();
264
331
  if (admitted && !duplicate) {
@@ -298,3 +365,4 @@ export function resetProductionPainCooldownForTest() {
298
365
  export function productionPainCooldownEntryCountForTest() {
299
366
  return cooldowns.size;
300
367
  }
368
+ export { hasCanonicalSchema as hasProductionPainSchema };
@@ -0,0 +1,15 @@
1
+ import { StoreEventEmitter, type TelemetryEvent } from '@principles/core/runtime-v2';
2
+ export type WorkspaceTelemetryPersistFailureSink = (detailJson: string) => void;
3
+ export declare class WorkspaceTelemetryEmitter extends StoreEventEmitter {
4
+ private readonly upstream;
5
+ private readonly sinkFilePath;
6
+ private readonly onPersistFailure;
7
+ constructor(upstream: StoreEventEmitter, workspaceDir: string, onPersistFailure: WorkspaceTelemetryPersistFailureSink);
8
+ /**
9
+ * Validate + persist (allowlist) + forward upstream. Never throws —
10
+ * persistence errors degrade to the injected failure sink (the upstream
11
+ * emit semantics are preserved unchanged).
12
+ */
13
+ emitTelemetry(event: TelemetryEvent): true;
14
+ private persist;
15
+ }
@@ -0,0 +1,74 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { StoreEventEmitter } from '@principles/core/runtime-v2';
4
+ /**
5
+ * PRI-634 A3 — workspace-scoped durable observability for critical runner
6
+ * telemetry (host-neutral; PRI-624 Slice C final integration).
7
+ *
8
+ * 背景:`StoreEventEmitter`(进程内单例)生产装配零订阅者、零持久化 ——
9
+ * `evaluator_adversarial_replay_skipped` 等关键事件发射即丢,事故(如
10
+ * 48371236 链)无法从日志取证。
11
+ *
12
+ * 为什么不是「给全局 storeEmitter 挂落盘订阅者」:singleton 的 telemetry
13
+ * payload 不含 workspaceDir,multi-workspace 进程下全局订阅者无法判断事件
14
+ * 归属,会重演 SystemLogger PRI-504 修过的 cross-workspace log leakage
15
+ * (ERR-092)。正确粒度是 **workspace-scoped**:本 emitter 在 consumer
16
+ * cycle 的 per-wake 装配处构造,只经手本 workspace runner 发出的事件。
17
+ *
18
+ * 持久化策略:**allowlist**,只落盘 4 类 critical events(与 telemetry-event
19
+ * schema 枚举同源),不做全量 telemetry 无差别写盘(日志量 + 隐私审计面
20
+ * 无谓扩大):
21
+ * - evaluator_adversarial_replay_skipped
22
+ * - evaluator_adversarial_replay
23
+ * - evaluator_rule_assembled
24
+ * - evaluator_rule_assembly_failed
25
+ *
26
+ * 落点 `<workspaceDir>/.pd/telemetry/critical-events.jsonl`(JSONL,一行一
27
+ * 事件,含完整 TelemetryEvent)。同步 append:事件量小(allowlist 限流),
28
+ * 换取 crash 前落盘的 durable 语义。写失败绝不影响 runner —— 降级为
29
+ * `onPersistFailure` 回调(宿主注入的结构化告警口;OpenClaw 是
30
+ * SystemLogger,Companion worker 是自己的事件口)。
31
+ */
32
+ const CRITICAL_EVENT_ALLOWLIST = new Set([
33
+ 'evaluator_adversarial_replay_skipped',
34
+ 'evaluator_adversarial_replay',
35
+ 'evaluator_rule_assembled',
36
+ 'evaluator_rule_assembly_failed',
37
+ ]);
38
+ export class WorkspaceTelemetryEmitter extends StoreEventEmitter {
39
+ upstream;
40
+ sinkFilePath;
41
+ onPersistFailure;
42
+ constructor(upstream, workspaceDir, onPersistFailure) {
43
+ super();
44
+ this.upstream = upstream;
45
+ this.onPersistFailure = onPersistFailure;
46
+ this.sinkFilePath = join(workspaceDir, '.pd', 'telemetry', 'critical-events.jsonl');
47
+ }
48
+ /**
49
+ * Validate + persist (allowlist) + forward upstream. Never throws —
50
+ * persistence errors degrade to the injected failure sink (the upstream
51
+ * emit semantics are preserved unchanged).
52
+ */
53
+ emitTelemetry(event) {
54
+ if (CRITICAL_EVENT_ALLOWLIST.has(event.eventType)) {
55
+ this.persist(event);
56
+ }
57
+ return this.upstream.emitTelemetry(event);
58
+ }
59
+ persist(event) {
60
+ try {
61
+ mkdirSync(dirname(this.sinkFilePath), { recursive: true });
62
+ appendFileSync(this.sinkFilePath, `${JSON.stringify(event)}\n`, 'utf8');
63
+ }
64
+ catch (err) {
65
+ // Non-fatal by contract: losing a telemetry line must never break the
66
+ // runner. Surface once per occurrence through the injected host sink.
67
+ this.onPersistFailure(JSON.stringify({
68
+ eventType: event.eventType,
69
+ sinkFilePath: this.sinkFilePath,
70
+ error: err instanceof Error ? err.message : String(err),
71
+ }));
72
+ }
73
+ }
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",