@principles/host-runtime 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/active-principle-prompt.js +5 -1
- package/dist/governance-observation-store.d.ts +196 -0
- package/dist/governance-observation-store.js +696 -0
- package/dist/governance-signal-admission.d.ts +228 -0
- package/dist/governance-signal-admission.js +853 -0
- package/dist/host-liveness-contract.d.ts +3 -0
- package/dist/host-liveness-contract.js +18 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +17 -0
- package/dist/internalization-consumer-cycle.d.ts +43 -0
- package/dist/internalization-consumer-cycle.js +468 -0
- package/dist/internalization-consumer-governance.d.ts +39 -0
- package/dist/internalization-consumer-governance.js +152 -0
- package/dist/legacy-rule-contract-symbols.d.ts +20 -0
- package/dist/legacy-rule-contract-symbols.js +124 -0
- package/dist/product-telemetry/consent-store.d.ts +107 -0
- package/dist/product-telemetry/consent-store.js +329 -0
- package/dist/product-telemetry/eligibility.d.ts +53 -0
- package/dist/product-telemetry/eligibility.js +69 -0
- package/dist/product-telemetry/exporter.d.ts +46 -0
- package/dist/product-telemetry/exporter.js +71 -0
- package/dist/product-telemetry/milestone-readers.d.ts +61 -0
- package/dist/product-telemetry/milestone-readers.js +234 -0
- package/dist/product-telemetry/service.d.ts +142 -0
- package/dist/product-telemetry/service.js +532 -0
- package/dist/product-telemetry/workspace-scope.d.ts +38 -0
- package/dist/product-telemetry/workspace-scope.js +64 -0
- package/dist/production-pain-evidence.d.ts +60 -0
- package/dist/production-pain-evidence.js +82 -12
- package/dist/production-rulehost-gate.d.ts +0 -1
- package/dist/production-rulehost-gate.js +47 -6
- package/dist/rule-implementation-runtime.js +0 -2
- package/dist/workspace-telemetry-emitter.d.ts +15 -0
- package/dist/workspace-telemetry-emitter.js +74 -0
- package/package.json +7 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import type { HostEvent, HostEventResult } from '@principles/core/host';
|
|
3
|
+
/** Shared with the governance admission path so both gate tool failures identically. */
|
|
4
|
+
export declare const PRODUCTION_WRITE_TOOLS: ReadonlySet<string>;
|
|
3
5
|
export interface PainEvidenceEntry {
|
|
4
6
|
sourceRef: string;
|
|
5
7
|
note: string;
|
|
@@ -16,9 +18,67 @@ export interface ProductionPainEnrichment {
|
|
|
16
18
|
}
|
|
17
19
|
export type PainEnrichmentProvider = (event: HostEvent) => unknown | Promise<unknown>;
|
|
18
20
|
export type PainDatabaseFactory = (databasePath: string) => Database.Database;
|
|
21
|
+
interface NormalizedOutcome {
|
|
22
|
+
failure: boolean;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
params: unknown;
|
|
27
|
+
result: unknown;
|
|
28
|
+
}
|
|
29
|
+
/** Structural input accepted by the canonical derivations (HostEvent satisfies it without a cast). */
|
|
30
|
+
export interface ProductionToolEventFields {
|
|
31
|
+
workspaceDir: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
turnId?: string;
|
|
34
|
+
toolName?: string;
|
|
35
|
+
toolInput?: unknown;
|
|
36
|
+
toolOutput?: unknown;
|
|
37
|
+
source: string;
|
|
38
|
+
}
|
|
39
|
+
export interface DerivedToolPainIdentity {
|
|
40
|
+
eventId: string;
|
|
41
|
+
painId: string;
|
|
42
|
+
outcome: NormalizedOutcome;
|
|
43
|
+
sanitizedParams: Record<string, unknown>;
|
|
44
|
+
paramsJson: string;
|
|
45
|
+
resultPreview: string | null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The canonical tool-pain identity derivation, exposed for the governance
|
|
49
|
+
* admission path (Codex Governance Closure SPEC §10): the same normalized
|
|
50
|
+
* fields fed to the live production handler derive the same deterministic
|
|
51
|
+
* `pain_host_<sha256>` id, so live and observation-delivered admissions of one
|
|
52
|
+
* tool call converge on one canonical pain.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deriveProductionToolPainIdentity(fields: ProductionToolEventFields & {
|
|
55
|
+
canonicalEventId?: string;
|
|
56
|
+
}): DerivedToolPainIdentity;
|
|
57
|
+
/**
|
|
58
|
+
* The canonical correction-pain identity derivation (SPEC §10/§12): deterministic,
|
|
59
|
+
* content-derived, retry-safe — replacing the legacy random `correction_<traceId>`
|
|
60
|
+
* ids. The identity is scoped by a stable OCCURRENCE identity supplied by the
|
|
61
|
+
* host (Codex hostTurnId / OpenClaw per-session turn index), so:
|
|
62
|
+
* - a retry / live+transcript replay of the SAME real occurrence → same pain;
|
|
63
|
+
* - the same correction text in a LATER real turn → a NEW pain occurrence.
|
|
64
|
+
* The raw text participates only in the in-memory hash — it is never persisted
|
|
65
|
+
* (the persistence boundary sanitizes it; see governance-signal-admission).
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveProductionCorrectionPainIdentity(fields: {
|
|
68
|
+
workspaceDir: string;
|
|
69
|
+
sessionId: string;
|
|
70
|
+
/** Stable occurrence identity of the real correction event (host turn id / per-session turn index). */
|
|
71
|
+
occurrenceId: string;
|
|
72
|
+
text: string;
|
|
73
|
+
}): {
|
|
74
|
+
eventId: string;
|
|
75
|
+
painId: string;
|
|
76
|
+
};
|
|
77
|
+
declare function hasCanonicalSchema(db: Database.Database): boolean;
|
|
19
78
|
export declare function createProductionPainEvidenceHandler(options?: {
|
|
20
79
|
painEnrichmentProvider?: PainEnrichmentProvider;
|
|
21
80
|
painDatabaseFactory?: PainDatabaseFactory;
|
|
22
81
|
}): (event: HostEvent) => Promise<HostEventResult>;
|
|
23
82
|
export declare function resetProductionPainCooldownForTest(): void;
|
|
24
83
|
export declare function productionPainCooldownEntryCountForTest(): number;
|
|
84
|
+
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
|
|
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:
|
|
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 {
|
|
106
|
+
const { outcome, sanitizedParams, canonicalEventId } = input;
|
|
104
107
|
const canonical = stable({
|
|
105
|
-
workspaceDir: path.resolve(
|
|
106
|
-
sessionId:
|
|
107
|
-
turnId:
|
|
108
|
-
toolName:
|
|
109
|
-
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,
|
|
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,6 +120,56 @@ 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' },
|
|
@@ -232,7 +285,16 @@ export function createProductionPainEvidenceHandler(options = {}) {
|
|
|
232
285
|
const triage = evaluateTriage({ sourceKind, score: painScore, consecutiveErrors: enrichment.consecutiveErrors, isRisky });
|
|
233
286
|
const trigger = evaluateTriggerController({ triageResult: triage, isOwnerManual: false, isCooldownActive: cooldownActive, isValid: true, score: painScore, sessionId: event.context.sessionId });
|
|
234
287
|
const admitted = outcome.failure && WRITE_TOOLS.has(toolName) && trigger.shouldCreateDiagnosticTask;
|
|
235
|
-
const { eventId, painId } = ids({
|
|
288
|
+
const { eventId, painId } = ids({
|
|
289
|
+
workspaceDir: event.context.workspaceDir,
|
|
290
|
+
sessionId: event.context.sessionId,
|
|
291
|
+
...(event.context.turnId !== undefined ? { turnId: event.context.turnId } : {}),
|
|
292
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
293
|
+
source: event.source,
|
|
294
|
+
...(enrichment.eventId ? { canonicalEventId: enrichment.eventId } : {}),
|
|
295
|
+
outcome,
|
|
296
|
+
sanitizedParams,
|
|
297
|
+
});
|
|
236
298
|
const createdAt = new Date().toISOString();
|
|
237
299
|
const paramsJson = stable(sanitizedParams);
|
|
238
300
|
const resultPreview = preview({ eventId, result: sanitizeValue(outcome.result, 0, event.context.workspaceDir) });
|
|
@@ -249,9 +311,16 @@ export function createProductionPainEvidenceHandler(options = {}) {
|
|
|
249
311
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
250
312
|
.run(event.context.sessionId, toolName, outcome.failure ? 'failure' : 'success', outcome.durationMs ?? null, outcome.exitCode, outcome.error ? outcome.error.split(/[\s:]/, 1)[0] : null, outcome.error ?? null, null, null, paramsJson, resultPreview, createdAt);
|
|
251
313
|
if (admitted) {
|
|
252
|
-
|
|
314
|
+
// Raw observation only (Pain Diagnosis Persistence SPEC §8): the
|
|
315
|
+
// pain row must record WHAT happened, not WHY. Attribution
|
|
316
|
+
// (People/Design/Assumption/Tooling) belongs to the Diagnostician.
|
|
317
|
+
// confidence stays null — no attribution confidence exists at
|
|
318
|
+
// detection time. origin remains 'system_infer' because origin is a
|
|
319
|
+
// declared enum (event-types.ts: who reported the pain), not an
|
|
320
|
+
// attribution claim.
|
|
321
|
+
const reason = `tool=${toolName}; error=${outcome.error ?? `exit=${outcome.exitCode}`}; path=${relativePath}`;
|
|
253
322
|
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
254
|
-
.run(event.context.sessionId, sourceObservation.failureSource ?? 'tool_failure', painScore, reason, painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild', 'system_infer',
|
|
323
|
+
.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);
|
|
255
324
|
}
|
|
256
325
|
})();
|
|
257
326
|
if (admitted && !duplicate) {
|
|
@@ -291,3 +360,4 @@ export function resetProductionPainCooldownForTest() {
|
|
|
291
360
|
export function productionPainCooldownEntryCountForTest() {
|
|
292
361
|
return cooldowns.size;
|
|
293
362
|
}
|
|
363
|
+
export { hasCanonicalSchema as hasProductionPainSchema };
|
|
@@ -10,7 +10,6 @@ export interface ProductionRuleContextRequest {
|
|
|
10
10
|
export type RuleContextProvider = (request: ProductionRuleContextRequest) => unknown | Promise<unknown>;
|
|
11
11
|
export interface RuleInputEnrichment {
|
|
12
12
|
currentGfi: number;
|
|
13
|
-
recentThinking: boolean;
|
|
14
13
|
epTier: number;
|
|
15
14
|
bashRisk: 'safe' | 'normal' | 'dangerous' | 'unknown';
|
|
16
15
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { buildRuleHostAction, estimateLineChanges, mergeDecisions, SqliteConnection, UNAVAILABLE_RULE_CONTEXT, validateRuleContextV2, validateRuleHostResult, } from '@principles/core/runtime-v2';
|
|
4
|
+
import { scanRetiredContractSymbols } from './legacy-rule-contract-symbols.js';
|
|
4
5
|
import { createNodeRuleImplementationRuntime, MAX_ACTIVE_RULES, RULE_BATCH_SOURCE_BYTES, RULE_SOURCE_BYTES, } from './rule-implementation-runtime.js';
|
|
5
6
|
const WARNING_LIMIT = 500;
|
|
6
7
|
const MAX_WARNINGS = 16;
|
|
@@ -76,7 +77,7 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
76
77
|
if (action.normalizedPath === null)
|
|
77
78
|
return { decision: 'allow', source: event.source, metadata: { evaluatedLiveRules: 0 } };
|
|
78
79
|
let context;
|
|
79
|
-
let enrichment = { currentGfi: 0,
|
|
80
|
+
let enrichment = { currentGfi: 0, epTier: 0, bashRisk: 'unknown' };
|
|
80
81
|
const request = {
|
|
81
82
|
workspaceDir: event.context.workspaceDir, sessionId: event.context.sessionId,
|
|
82
83
|
targetPath: action.normalizedPath, toolName: input.toolName, rawPayload: event.rawPayload,
|
|
@@ -85,9 +86,9 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
85
86
|
try {
|
|
86
87
|
const candidate = await withinGateDeadline(options.ruleInputEnrichmentProvider(request), startedAt);
|
|
87
88
|
if (isRecord(candidate) && typeof candidate.currentGfi === 'number' && Number.isFinite(candidate.currentGfi)
|
|
88
|
-
&& typeof candidate.
|
|
89
|
+
&& typeof candidate.epTier === 'number' && Number.isFinite(candidate.epTier)
|
|
89
90
|
&& (candidate.bashRisk === 'safe' || candidate.bashRisk === 'normal' || candidate.bashRisk === 'dangerous' || candidate.bashRisk === 'unknown')) {
|
|
90
|
-
enrichment = { currentGfi: candidate.currentGfi,
|
|
91
|
+
enrichment = { currentGfi: candidate.currentGfi, epTier: candidate.epTier, bashRisk: candidate.bashRisk };
|
|
91
92
|
}
|
|
92
93
|
else
|
|
93
94
|
addWarning(warnings, 'rule_input_enrichment_invalid', 'repair the host enrichment provider');
|
|
@@ -127,11 +128,25 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
127
128
|
const connection = new SqliteConnection({ workspaceDir: event.context.workspaceDir, readonly: true, bootstrapIfMissing: false });
|
|
128
129
|
const candidates = [];
|
|
129
130
|
try {
|
|
131
|
+
const globalPause = connection.getDb().prepare(`
|
|
132
|
+
SELECT pause_id FROM global_rulecode_pauses WHERE status = 'paused' LIMIT 1
|
|
133
|
+
`).get();
|
|
134
|
+
if (globalPause !== undefined) {
|
|
135
|
+
return {
|
|
136
|
+
decision: 'allow',
|
|
137
|
+
source: event.source,
|
|
138
|
+
warnings: [boundedWarning('global_rulecode_pause_active', 'review the incident in the Owner Console before releasing the global pause')],
|
|
139
|
+
metadata: { evaluatedLiveRules: 0 },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
130
142
|
const rows = connection.getDb().prepare(`
|
|
131
143
|
SELECT a.activation_id, a.artifact_id, a.target_ref, a.action,
|
|
144
|
+
c.enforcement, c.isolation_decision_id,
|
|
132
145
|
p.source_rule_id, p.source_principle_id,
|
|
133
146
|
length(CAST(p.content_json AS BLOB)) AS content_bytes
|
|
134
|
-
FROM activations a
|
|
147
|
+
FROM activations a
|
|
148
|
+
JOIN pi_artifacts p ON a.artifact_id = p.artifact_id
|
|
149
|
+
LEFT JOIN activation_control_states c ON a.activation_id = c.activation_id
|
|
135
150
|
WHERE a.channel = 'code_tool_hook' AND a.deactivated_at IS NULL
|
|
136
151
|
ORDER BY a.activated_at ASC
|
|
137
152
|
LIMIT ?
|
|
@@ -158,6 +173,15 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
158
173
|
addWarning(warnings, 'activation_row_invalid', 'deactivate and recreate the malformed activation');
|
|
159
174
|
continue;
|
|
160
175
|
}
|
|
176
|
+
const activationId = typeof row.activation_id === 'string' ? row.activation_id : 'unknown';
|
|
177
|
+
if (row.enforcement === 'safety_isolated') {
|
|
178
|
+
addWarning(warnings, `activation_safety_isolated: ${activationId}`, 'recover the activation to shadow after reviewing safety evidence');
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (row.enforcement !== 'eligible') {
|
|
182
|
+
addWarning(warnings, `activation_control_state_invalid: ${activationId}`, 'repair the activation control state before RuleCode enforcement');
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
161
185
|
const group = groups.get(row.target_ref) ?? [];
|
|
162
186
|
group.push(row);
|
|
163
187
|
groups.set(row.target_ref, group);
|
|
@@ -216,6 +240,23 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
216
240
|
}
|
|
217
241
|
const ruleId = typeof content.ruleId === 'string' ? content.ruleId : typeof row.source_rule_id === 'string' ? row.source_rule_id : artifactId;
|
|
218
242
|
const principleId = typeof content.principleId === 'string' ? content.principleId : typeof row.source_principle_id === 'string' ? row.source_principle_id : ruleId;
|
|
243
|
+
// Retired-contract backstop (2026-08-19; host-liveness correction
|
|
244
|
+
// 2026-08-21):
|
|
245
|
+
// persisted LIVE RuleCode that references removed RuleHost
|
|
246
|
+
// contract symbols can never run safely against the new contract
|
|
247
|
+
// — reads silently resolve to undefined and change owner-approved
|
|
248
|
+
// behavior. The incompatible activation is therefore skipped and
|
|
249
|
+
// surfaced with an actionable warning. It must not turn a runtime
|
|
250
|
+
// compatibility defect into a host-wide deny; healthy sibling
|
|
251
|
+
// rules continue to evaluate. The scan is a local copy (see
|
|
252
|
+
// legacy-rule-contract-symbols.ts) so the published bundle keeps
|
|
253
|
+
// working against the currently published core.
|
|
254
|
+
const retiredSymbols = scanRetiredContractSymbols(content.implementationCode);
|
|
255
|
+
if (retiredSymbols.length > 0) {
|
|
256
|
+
const nextAction = 'migrate the RuleCode off the retired contract symbols or deactivate the activation, then re-approve a migrated rule';
|
|
257
|
+
addWarning(warnings, `legacy_rule_contract_dependency: ${retiredSymbols.join(', ')} (activation=${activationId})`, nextAction);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
219
260
|
const fallbackMeta = { name: activationId, version: '1', ruleId, coversCondition: 'all' };
|
|
220
261
|
candidates.push({ implId: activationId, ruleId, principleId, meta: isRuleMeta(content.meta) ? content.meta : fallbackMeta, source: content.implementationCode });
|
|
221
262
|
}
|
|
@@ -226,8 +267,8 @@ export function createProductionRuleHostGate(options = {}) {
|
|
|
226
267
|
}
|
|
227
268
|
const hostInput = {
|
|
228
269
|
action,
|
|
229
|
-
workspace: { isRiskPath: false
|
|
230
|
-
session: { sessionId: event.context.sessionId, currentGfi: enrichment.currentGfi
|
|
270
|
+
workspace: { isRiskPath: false },
|
|
271
|
+
session: { sessionId: event.context.sessionId, currentGfi: enrichment.currentGfi },
|
|
231
272
|
evolution: { epTier: enrichment.epTier },
|
|
232
273
|
derived: { estimatedLineChanges: estimateLineChanges({ toolName: input.toolName, params: input.params }), bashRisk: enrichment.bashRisk },
|
|
233
274
|
...(context ? { context } : {}),
|
|
@@ -37,8 +37,6 @@ try {
|
|
|
37
37
|
' getToolName: () => __pdCallInput.action.toolName,' +
|
|
38
38
|
' getEstimatedLineChanges: () => __pdCallInput.derived.estimatedLineChanges,' +
|
|
39
39
|
' getBashRisk: () => __pdCallInput.derived.bashRisk,' +
|
|
40
|
-
' hasPlanFile: () => __pdCallInput.workspace.hasPlanFile,' +
|
|
41
|
-
' getPlanStatus: () => __pdCallInput.workspace.planStatus,' +
|
|
42
40
|
' getEpTier: () => __pdCallInput.evolution.epTier' +
|
|
43
41
|
'});', { filename: rule.filename + '.bootstrap' }
|
|
44
42
|
).runInContext(context, { timeout: 1000 });
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,16 +22,21 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@principles/core": "^1.74.1",
|
|
25
|
+
"@principles/install-layout": "0.1.0",
|
|
25
26
|
"better-sqlite3": "^13.0.3",
|
|
26
27
|
"js-yaml": "^5.2.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@types/better-sqlite3": "^
|
|
30
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
30
31
|
"@types/node": "^26.1.2",
|
|
31
32
|
"@types/js-yaml": "^4.0.9",
|
|
32
33
|
"typescript": "^7.0.2",
|
|
33
34
|
"vite": "^8.0.16",
|
|
34
35
|
"vitest": "^4.1.10"
|
|
35
36
|
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/csuzngjh/principles.git"
|
|
40
|
+
},
|
|
36
41
|
"license": "MIT"
|
|
37
42
|
}
|