@principles/host-runtime 0.1.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.d.ts +17 -0
- package/dist/active-principle-prompt.js +102 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +148 -0
- package/dist/pd-config.d.ts +36 -0
- package/dist/pd-config.js +98 -0
- package/dist/production-pain-evidence.d.ts +24 -0
- package/dist/production-pain-evidence.js +293 -0
- package/dist/production-rulehost-gate.d.ts +23 -0
- package/dist/production-rulehost-gate.js +294 -0
- package/dist/rule-implementation-runtime.d.ts +25 -0
- package/dist/rule-implementation-runtime.js +114 -0
- package/package.json +37 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ActivePrinciplePromptResult {
|
|
2
|
+
additionalContext: string;
|
|
3
|
+
principleIds: string[];
|
|
4
|
+
activationIds: string[];
|
|
5
|
+
artifactIds: string[];
|
|
6
|
+
warnings: string[];
|
|
7
|
+
budget: number;
|
|
8
|
+
truncated: boolean;
|
|
9
|
+
excludedPrincipleIds: string[];
|
|
10
|
+
excludedCount: number;
|
|
11
|
+
exclusionReason?: 'host_principle_overlap';
|
|
12
|
+
allValidatedPrinciplesExcluded: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildActivePrinciplePromptContext(input: {
|
|
15
|
+
workspaceDir: string;
|
|
16
|
+
excludePrincipleIds?: ReadonlySet<string>;
|
|
17
|
+
}): Promise<ActivePrinciplePromptResult>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { RUNTIME_V2_PRINCIPLE_BUDGET, SqliteActivationStateStore, SqliteConnection, SqlitePIArtifactStore, computeFeatureFlagsFromConfig, filterPromptActivations, renderPrinciplesToDirectives, resolvePrincipleFromArtifact, } from '@principles/core/runtime-v2';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { escapeXml } from '@principles/core/prompt-builder';
|
|
5
|
+
import { loadPdConfigForPlugin } from './pd-config.js';
|
|
6
|
+
export async function buildActivePrinciplePromptContext(input) {
|
|
7
|
+
const warnings = [];
|
|
8
|
+
const principles = [];
|
|
9
|
+
const excludedPrincipleIds = [];
|
|
10
|
+
const config = loadPdConfigForPlugin(input.workspaceDir);
|
|
11
|
+
if (!config.ok) {
|
|
12
|
+
warnings.push(...config.errors.map((error) => `config_invalid: ${error.reason}; nextAction=${error.nextAction}`));
|
|
13
|
+
}
|
|
14
|
+
const promptFlag = computeFeatureFlagsFromConfig(config.effective).flags.prompt;
|
|
15
|
+
if (!promptFlag?.enabled) {
|
|
16
|
+
warnings.push('prompt_feature_disabled; nextAction=set features.prompt.enabled=true in .pd/config.yaml');
|
|
17
|
+
return { additionalContext: '', principleIds: [], activationIds: [], artifactIds: [], warnings, budget: RUNTIME_V2_PRINCIPLE_BUDGET, truncated: false, excludedPrincipleIds, excludedCount: 0, allValidatedPrinciplesExcluded: false };
|
|
18
|
+
}
|
|
19
|
+
let connection;
|
|
20
|
+
const stateDbPath = path.join(input.workspaceDir, '.pd', 'state.db');
|
|
21
|
+
if (!fs.existsSync(stateDbPath)) {
|
|
22
|
+
warnings.push('activation_db_not_found; nextAction=initialize_workspace_runtime_state');
|
|
23
|
+
return {
|
|
24
|
+
additionalContext: '', principleIds: [], activationIds: [], artifactIds: [], warnings,
|
|
25
|
+
budget: RUNTIME_V2_PRINCIPLE_BUDGET, truncated: false, excludedPrincipleIds,
|
|
26
|
+
excludedCount: 0, allValidatedPrinciplesExcluded: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
connection = new SqliteConnection({ workspaceDir: input.workspaceDir, readonly: true, bootstrapIfMissing: false });
|
|
31
|
+
const activations = filterPromptActivations(await new SqliteActivationStateStore(connection).listPromptActivations());
|
|
32
|
+
const artifactStore = new SqlitePIArtifactStore(connection);
|
|
33
|
+
for (const activation of activations) {
|
|
34
|
+
let artifact;
|
|
35
|
+
try {
|
|
36
|
+
artifact = await artifactStore.getArtifactById(activation.artifactId);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
warnings.push(`artifact_query_failed: artifactId=${activation.artifactId} reason=${message}; nextAction=check_pi_artifacts_table`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (artifact === null) {
|
|
44
|
+
warnings.push(`artifact_not_found: artifactId=${activation.artifactId}; nextAction=check_pi_artifacts_table_or_remove_stale_activation`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const resolved = resolvePrincipleFromArtifact({
|
|
48
|
+
artifact_id: artifact.artifactId,
|
|
49
|
+
artifact_kind: artifact.artifactKind,
|
|
50
|
+
content_json: artifact.contentJson,
|
|
51
|
+
validation_status: artifact.validationStatus,
|
|
52
|
+
}, activation);
|
|
53
|
+
if (!resolved.ok) {
|
|
54
|
+
warnings.push(resolved.warning);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (input.excludePrincipleIds?.has(resolved.principle.principleId)) {
|
|
58
|
+
excludedPrincipleIds.push(resolved.principle.principleId);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
principles.push(resolved.principle);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
warnings.push(`activation_db_unreadable: ${message}; nextAction=check_workspace_pd_state_db`);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
connection?.close();
|
|
71
|
+
}
|
|
72
|
+
const included = [];
|
|
73
|
+
let additionalContext = '';
|
|
74
|
+
let truncated = false;
|
|
75
|
+
for (const principle of principles) {
|
|
76
|
+
const candidate = [...included, principle];
|
|
77
|
+
const candidateContext = renderPrinciplesToDirectives(candidate, new Set(candidate.map((entry) => entry.principleId)), escapeXml);
|
|
78
|
+
if (candidateContext.length > RUNTIME_V2_PRINCIPLE_BUDGET) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
included.push(principle);
|
|
83
|
+
additionalContext = candidateContext;
|
|
84
|
+
}
|
|
85
|
+
if (truncated) {
|
|
86
|
+
warnings.push('prompt_context_truncated: production_prompt_cap; nextAction=reduce_active_prompt_principles');
|
|
87
|
+
}
|
|
88
|
+
const injectedIds = new Set(included.map((principle) => principle.principleId));
|
|
89
|
+
return {
|
|
90
|
+
additionalContext,
|
|
91
|
+
principleIds: [...injectedIds],
|
|
92
|
+
activationIds: included.map((principle) => principle.activationId),
|
|
93
|
+
artifactIds: included.map((principle) => principle.artifactId),
|
|
94
|
+
warnings,
|
|
95
|
+
budget: RUNTIME_V2_PRINCIPLE_BUDGET,
|
|
96
|
+
truncated,
|
|
97
|
+
excludedPrincipleIds,
|
|
98
|
+
excludedCount: excludedPrincipleIds.length,
|
|
99
|
+
allValidatedPrinciplesExcluded: excludedPrincipleIds.length > 0 && principles.length === 0,
|
|
100
|
+
...(excludedPrincipleIds.length > 0 ? { exclusionReason: 'host_principle_overlap' } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type HostEvent, type HostEventResult } from '@principles/core/host';
|
|
2
|
+
import { buildActivePrinciplePromptContext } from './active-principle-prompt.js';
|
|
3
|
+
import { type RuleContextProvider, type RuleInputEnrichmentProvider } from './production-rulehost-gate.js';
|
|
4
|
+
import type { RuleImplementationRuntime } from './rule-implementation-runtime.js';
|
|
5
|
+
import { type PainDatabaseFactory, type PainEnrichmentProvider } from './production-pain-evidence.js';
|
|
6
|
+
export * from './active-principle-prompt.js';
|
|
7
|
+
export * from './pd-config.js';
|
|
8
|
+
export * from './production-rulehost-gate.js';
|
|
9
|
+
export * from './rule-implementation-runtime.js';
|
|
10
|
+
export * from './production-pain-evidence.js';
|
|
11
|
+
export declare const HOST_RUNTIME_ROUTES: readonly ['before_prompt_build', 'before_tool_call', 'after_tool_call'];
|
|
12
|
+
export type HostRuntimeRoute = (typeof HOST_RUNTIME_ROUTES)[number];
|
|
13
|
+
export type HostRuntimePort = (event: HostEvent) => HostEventResult | Promise<HostEventResult>;
|
|
14
|
+
export interface HostRuntimeOptions {
|
|
15
|
+
beforePromptBuild: HostRuntimePort;
|
|
16
|
+
beforeToolCall: HostRuntimePort;
|
|
17
|
+
afterToolCall: HostRuntimePort;
|
|
18
|
+
}
|
|
19
|
+
export interface HostRuntimeHealth {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
workspaceDir: string;
|
|
22
|
+
routes: readonly HostRuntimeRoute[];
|
|
23
|
+
reason?: string;
|
|
24
|
+
nextAction?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface HostRuntime {
|
|
27
|
+
dispatch(event: HostEvent): Promise<HostEventResult>;
|
|
28
|
+
health(workspaceDir: string): Promise<HostRuntimeHealth>;
|
|
29
|
+
}
|
|
30
|
+
export declare class HostRuntimeDispatchError extends Error {
|
|
31
|
+
readonly reason: 'invalid_host_event' | 'unsupported_host_event' | 'invalid_handler_result' | 'lineage_mismatch';
|
|
32
|
+
readonly nextAction: string;
|
|
33
|
+
constructor(reason: 'invalid_host_event' | 'unsupported_host_event' | 'invalid_handler_result' | 'lineage_mismatch', nextAction: string);
|
|
34
|
+
}
|
|
35
|
+
export declare function createHostRuntime(options: HostRuntimeOptions): HostRuntime;
|
|
36
|
+
export declare function createProductionHostRuntime(options?: Partial<Pick<HostRuntimeOptions, 'afterToolCall'>> & {
|
|
37
|
+
beforeToolCall?: HostRuntimePort;
|
|
38
|
+
beforePromptBuild?: (event: HostEvent, prompt: Awaited<ReturnType<typeof buildActivePrinciplePromptContext>>) => HostEventResult | Promise<HostEventResult>;
|
|
39
|
+
promptExcludePrincipleIds?: (event: HostEvent) => ReadonlySet<string>;
|
|
40
|
+
ruleContextProvider?: RuleContextProvider;
|
|
41
|
+
ruleInputEnrichmentProvider?: RuleInputEnrichmentProvider;
|
|
42
|
+
ruleImplementationRuntime?: RuleImplementationRuntime;
|
|
43
|
+
painEnrichmentProvider?: PainEnrichmentProvider;
|
|
44
|
+
painDatabaseFactory?: PainDatabaseFactory;
|
|
45
|
+
}): HostRuntime;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { isHostEvent, isHostEventResult, } from '@principles/core/host';
|
|
2
|
+
import { buildActivePrinciplePromptContext } from './active-principle-prompt.js';
|
|
3
|
+
import { createProductionRuleHostGate } from './production-rulehost-gate.js';
|
|
4
|
+
import { createProductionPainEvidenceHandler } from './production-pain-evidence.js';
|
|
5
|
+
export * from './active-principle-prompt.js';
|
|
6
|
+
export * from './pd-config.js';
|
|
7
|
+
export * from './production-rulehost-gate.js';
|
|
8
|
+
export * from './rule-implementation-runtime.js';
|
|
9
|
+
export * from './production-pain-evidence.js';
|
|
10
|
+
export const HOST_RUNTIME_ROUTES = [
|
|
11
|
+
'before_prompt_build',
|
|
12
|
+
'before_tool_call',
|
|
13
|
+
'after_tool_call',
|
|
14
|
+
];
|
|
15
|
+
export class HostRuntimeDispatchError extends Error {
|
|
16
|
+
reason;
|
|
17
|
+
nextAction;
|
|
18
|
+
constructor(reason, nextAction) {
|
|
19
|
+
super(`${reason}: ${nextAction}`);
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
this.nextAction = nextAction;
|
|
22
|
+
this.name = 'HostRuntimeDispatchError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function portFor(event, options) {
|
|
26
|
+
switch (event.kind) {
|
|
27
|
+
case 'before_prompt_build':
|
|
28
|
+
return options.beforePromptBuild;
|
|
29
|
+
case 'before_tool_call':
|
|
30
|
+
return options.beforeToolCall;
|
|
31
|
+
case 'after_tool_call':
|
|
32
|
+
return options.afterToolCall;
|
|
33
|
+
default:
|
|
34
|
+
throw new HostRuntimeDispatchError('unsupported_host_event', `Only ${HOST_RUNTIME_ROUTES.join(', ')} are supported by the MVP host runtime`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isNonEmptyString(value) {
|
|
38
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
39
|
+
}
|
|
40
|
+
function isAbsoluteWorkspace(value) {
|
|
41
|
+
return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value);
|
|
42
|
+
}
|
|
43
|
+
function hasValidRouteSemantics(event) {
|
|
44
|
+
if (!isNonEmptyString(event.context.workspaceDir) || !isAbsoluteWorkspace(event.context.workspaceDir))
|
|
45
|
+
return false;
|
|
46
|
+
if (!isNonEmptyString(event.context.sessionId) || !isNonEmptyString(event.source))
|
|
47
|
+
return false;
|
|
48
|
+
if (event.kind === 'before_tool_call' || event.kind === 'after_tool_call') {
|
|
49
|
+
return isNonEmptyString(event.context.toolName);
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function hasNonEmptyOptionalString(value) {
|
|
54
|
+
return value === undefined || isNonEmptyString(value);
|
|
55
|
+
}
|
|
56
|
+
function hasValidResultSemantics(event, result) {
|
|
57
|
+
if (result.warnings !== undefined && (!Array.isArray(result.warnings) || !result.warnings.every(isNonEmptyString)))
|
|
58
|
+
return false;
|
|
59
|
+
if (result.metadata !== undefined && (typeof result.metadata !== 'object' || result.metadata === null || Array.isArray(result.metadata)))
|
|
60
|
+
return false;
|
|
61
|
+
if (!isNonEmptyString(result.source) || !hasNonEmptyOptionalString(result.reason) || !hasNonEmptyOptionalString(result.additionalContext)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const hasReason = result.reason !== undefined;
|
|
65
|
+
const hasModifiedInput = result.modifiedInput !== undefined;
|
|
66
|
+
const hasAdditionalContext = result.additionalContext !== undefined;
|
|
67
|
+
switch (event.kind) {
|
|
68
|
+
case 'before_prompt_build':
|
|
69
|
+
return (result.decision === 'allow' || result.decision === 'modify') && !hasReason && !hasModifiedInput;
|
|
70
|
+
case 'before_tool_call':
|
|
71
|
+
if (result.decision === 'observe')
|
|
72
|
+
return false;
|
|
73
|
+
if (result.decision === 'deny')
|
|
74
|
+
return hasReason && !hasModifiedInput && !hasAdditionalContext;
|
|
75
|
+
if (hasReason)
|
|
76
|
+
return false;
|
|
77
|
+
return !hasModifiedInput || result.decision === 'modify';
|
|
78
|
+
case 'after_tool_call':
|
|
79
|
+
return result.decision === 'observe' && !hasReason && !hasModifiedInput && !hasAdditionalContext;
|
|
80
|
+
default:
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function createHostRuntime(options) {
|
|
85
|
+
return {
|
|
86
|
+
async dispatch(event) {
|
|
87
|
+
if (!isHostEvent(event) || !hasValidRouteSemantics(event)) {
|
|
88
|
+
throw new HostRuntimeDispatchError('invalid_host_event', 'Decode and validate the host event before dispatch');
|
|
89
|
+
}
|
|
90
|
+
const result = await portFor(event, options)(event);
|
|
91
|
+
if (!isHostEventResult(result) || !hasValidResultSemantics(event, result)) {
|
|
92
|
+
throw new HostRuntimeDispatchError('invalid_handler_result', `Handler for ${event.kind} must return a valid HostEventResult`);
|
|
93
|
+
}
|
|
94
|
+
if (result.source !== event.source) {
|
|
95
|
+
throw new HostRuntimeDispatchError('lineage_mismatch', `Handler result source must match event source ${event.source}`);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
},
|
|
99
|
+
async health(workspaceDir) {
|
|
100
|
+
if (workspaceDir.trim().length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
workspaceDir,
|
|
104
|
+
routes: HOST_RUNTIME_ROUTES,
|
|
105
|
+
reason: 'workspace_dir_missing',
|
|
106
|
+
nextAction: 'Resolve an absolute workspace directory before probing host runtime health',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!isAbsoluteWorkspace(workspaceDir)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
workspaceDir,
|
|
113
|
+
routes: HOST_RUNTIME_ROUTES,
|
|
114
|
+
reason: 'workspace_dir_invalid',
|
|
115
|
+
nextAction: 'Resolve an absolute workspace directory before probing host runtime health',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, workspaceDir, routes: HOST_RUNTIME_ROUTES };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function createProductionHostRuntime(options = {}) {
|
|
123
|
+
const productionGate = createProductionRuleHostGate({
|
|
124
|
+
...(options.ruleContextProvider ? { ruleContextProvider: options.ruleContextProvider } : {}),
|
|
125
|
+
...(options.ruleInputEnrichmentProvider ? { ruleInputEnrichmentProvider: options.ruleInputEnrichmentProvider } : {}),
|
|
126
|
+
...(options.ruleImplementationRuntime ? { implementationRuntime: options.ruleImplementationRuntime } : {}),
|
|
127
|
+
});
|
|
128
|
+
return createHostRuntime({
|
|
129
|
+
afterToolCall: options.afterToolCall ?? createProductionPainEvidenceHandler({
|
|
130
|
+
...(options.painEnrichmentProvider ? { painEnrichmentProvider: options.painEnrichmentProvider } : {}),
|
|
131
|
+
...(options.painDatabaseFactory ? { painDatabaseFactory: options.painDatabaseFactory } : {}),
|
|
132
|
+
}),
|
|
133
|
+
beforeToolCall: options.beforeToolCall ?? productionGate,
|
|
134
|
+
async beforePromptBuild(event) {
|
|
135
|
+
const prompt = await buildActivePrinciplePromptContext({
|
|
136
|
+
workspaceDir: event.context.workspaceDir,
|
|
137
|
+
excludePrincipleIds: options.promptExcludePrincipleIds?.(event),
|
|
138
|
+
});
|
|
139
|
+
if (options.beforePromptBuild)
|
|
140
|
+
return options.beforePromptBuild(event, prompt);
|
|
141
|
+
return {
|
|
142
|
+
decision: prompt.additionalContext.length > 0 ? 'modify' : 'allow',
|
|
143
|
+
source: event.source,
|
|
144
|
+
...(prompt.additionalContext.length > 0 ? { additionalContext: prompt.additionalContext } : {}),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type EffectivePdConfig } from '@principles/core/runtime-v2';
|
|
2
|
+
export declare const PD_CONFIG_DIR = ".pd";
|
|
3
|
+
export declare const PD_CONFIG_FILENAME = "config.yaml";
|
|
4
|
+
export interface PluginConfigLoadResult {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
effective: EffectivePdConfig;
|
|
7
|
+
source: 'defaults' | 'user_config' | 'malformed';
|
|
8
|
+
configPath: string;
|
|
9
|
+
warnings: string[];
|
|
10
|
+
errors: {
|
|
11
|
+
path: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
nextAction: string;
|
|
14
|
+
}[];
|
|
15
|
+
}
|
|
16
|
+
export type PdWorkspaceResolution = {
|
|
17
|
+
ok: true;
|
|
18
|
+
workspaceDir: string;
|
|
19
|
+
configPath: string;
|
|
20
|
+
source: 'nearest' | 'legacy_fallback';
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
cwd: string;
|
|
24
|
+
reason: 'cwd_not_absolute' | 'config_not_found';
|
|
25
|
+
nextAction: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function getPdConfigPath(workspaceDir: string): string;
|
|
28
|
+
export declare function resolveNearestPdWorkspace(cwd: string, legacyFallback?: string): PdWorkspaceResolution;
|
|
29
|
+
export declare function loadPdConfigForPlugin(workspaceDir: string): PluginConfigLoadResult;
|
|
30
|
+
export declare function loadFeatureFlagFromConfig(workspaceDir: string, flagId: string, logger?: {
|
|
31
|
+
warn?: (message: string) => void;
|
|
32
|
+
info?: (message: string) => void;
|
|
33
|
+
}): {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
source: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
|
+
import { computeEffectivePdConfig, computeFeatureFlagsFromConfig, validatePdConfig, } from '@principles/core/runtime-v2';
|
|
5
|
+
export const PD_CONFIG_DIR = '.pd';
|
|
6
|
+
export const PD_CONFIG_FILENAME = 'config.yaml';
|
|
7
|
+
export function getPdConfigPath(workspaceDir) {
|
|
8
|
+
return path.join(workspaceDir, PD_CONFIG_DIR, PD_CONFIG_FILENAME);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A config candidate counts only when stat still sees a regular file.
|
|
12
|
+
* stat alone (no existsSync+stat pair) closes the TOCTOU window: the
|
|
13
|
+
* installer replaces .pd/config.yaml via atomic rename, and a hook that
|
|
14
|
+
* resolves the workspace mid-replacement would otherwise see exists=true
|
|
15
|
+
* then throw from statSync — crashing the hook before its fail-open
|
|
16
|
+
* handling instead of returning a structured resolution result.
|
|
17
|
+
*/
|
|
18
|
+
function isConfigFileAt(dir) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.statSync(getPdConfigPath(dir)).isFile();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function resolveNearestPdWorkspace(cwd, legacyFallback) {
|
|
27
|
+
if (!path.isAbsolute(cwd)) {
|
|
28
|
+
return { ok: false, cwd, reason: 'cwd_not_absolute', nextAction: 'Provide an absolute cwd before resolving the PD Workspace' };
|
|
29
|
+
}
|
|
30
|
+
let current = path.resolve(cwd);
|
|
31
|
+
while (true) {
|
|
32
|
+
if (isConfigFileAt(current)) {
|
|
33
|
+
return { ok: true, workspaceDir: current, configPath: getPdConfigPath(current), source: 'nearest' };
|
|
34
|
+
}
|
|
35
|
+
const parent = path.dirname(current);
|
|
36
|
+
if (parent === current)
|
|
37
|
+
break;
|
|
38
|
+
current = parent;
|
|
39
|
+
}
|
|
40
|
+
if (legacyFallback && path.isAbsolute(legacyFallback)) {
|
|
41
|
+
const workspaceDir = path.resolve(legacyFallback);
|
|
42
|
+
if (isConfigFileAt(workspaceDir)) {
|
|
43
|
+
return { ok: true, workspaceDir, configPath: getPdConfigPath(workspaceDir), source: 'legacy_fallback' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
cwd,
|
|
49
|
+
reason: 'config_not_found',
|
|
50
|
+
nextAction: 'Create .pd/config.yaml in the Workspace or provide an absolute legacy fallback containing it',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function loadPdConfigForPlugin(workspaceDir) {
|
|
54
|
+
const configPath = getPdConfigPath(workspaceDir);
|
|
55
|
+
if (!fs.existsSync(configPath)) {
|
|
56
|
+
const effective = computeEffectivePdConfig(null);
|
|
57
|
+
return { ok: true, effective, source: 'defaults', configPath, warnings: effective.warnings, errors: [] };
|
|
58
|
+
}
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
return {
|
|
66
|
+
ok: false, effective: computeEffectivePdConfig(null), source: 'malformed', configPath, warnings: [],
|
|
67
|
+
errors: [{ path: '', reason: `Failed to read .pd/config.yaml: ${message}`, nextAction: 'Check file permissions for .pd/config.yaml' }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return {
|
|
77
|
+
ok: false, effective: computeEffectivePdConfig(null), source: 'malformed', configPath, warnings: [],
|
|
78
|
+
errors: [{ path: '', reason: `YAML parse error in .pd/config.yaml: ${message}`, nextAction: 'Fix YAML syntax in .pd/config.yaml' }],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const validation = validatePdConfig(parsed);
|
|
82
|
+
if (!validation.ok) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false, effective: computeEffectivePdConfig(null), source: 'malformed', configPath, warnings: [],
|
|
85
|
+
errors: validation.errors.map(({ path: errorPath, reason, nextAction }) => ({ path: errorPath, reason, nextAction })),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const effective = computeEffectivePdConfig(validation.value);
|
|
89
|
+
return { ok: true, effective, source: 'user_config', configPath, warnings: effective.warnings, errors: [] };
|
|
90
|
+
}
|
|
91
|
+
export function loadFeatureFlagFromConfig(workspaceDir, flagId, logger) {
|
|
92
|
+
const result = loadPdConfigForPlugin(workspaceDir);
|
|
93
|
+
const flag = computeFeatureFlagsFromConfig(result.effective).flags[flagId];
|
|
94
|
+
if (!result.ok) {
|
|
95
|
+
logger?.warn?.(`[PD:Config] Config validation failed: ${result.errors.map((error) => error.reason).join('; ')} — using defaults`);
|
|
96
|
+
}
|
|
97
|
+
return { enabled: flag?.enabled ?? false, source: result.source };
|
|
98
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import type { HostEvent, HostEventResult } from '@principles/core/host';
|
|
3
|
+
export interface PainEvidenceEntry {
|
|
4
|
+
sourceRef: string;
|
|
5
|
+
note: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProductionPainEnrichment {
|
|
8
|
+
eventId?: string;
|
|
9
|
+
painScore?: number;
|
|
10
|
+
isRisky?: boolean;
|
|
11
|
+
consecutiveErrors?: number;
|
|
12
|
+
relativePath?: string;
|
|
13
|
+
agentId?: string;
|
|
14
|
+
errorHash?: string;
|
|
15
|
+
evidence?: readonly PainEvidenceEntry[];
|
|
16
|
+
}
|
|
17
|
+
export type PainEnrichmentProvider = (event: HostEvent) => unknown | Promise<unknown>;
|
|
18
|
+
export type PainDatabaseFactory = (databasePath: string) => Database.Database;
|
|
19
|
+
export declare function createProductionPainEvidenceHandler(options?: {
|
|
20
|
+
painEnrichmentProvider?: PainEnrichmentProvider;
|
|
21
|
+
painDatabaseFactory?: PainDatabaseFactory;
|
|
22
|
+
}): (event: HostEvent) => Promise<HostEventResult>;
|
|
23
|
+
export declare function resetProductionPainCooldownForTest(): void;
|
|
24
|
+
export declare function productionPainCooldownEntryCountForTest(): number;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { buildToolFailureObservation, evaluateTriage, evaluateTriggerController, resolveSourceKind, sanitizeToolParams, sanitizeValue, } from '@principles/core/runtime-v2';
|
|
6
|
+
const WRITE_TOOLS = new Set(['write', 'edit', 'apply_patch', 'write_file', 'edit_file', 'replace']);
|
|
7
|
+
const MAX_PREVIEW = 500;
|
|
8
|
+
const PAIN_COOLDOWN_WINDOW_MS = 15 * 60 * 1000;
|
|
9
|
+
const cooldowns = new Map();
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function field(value, key) {
|
|
14
|
+
if (!isRecord(value) || !Object.hasOwn(value, key))
|
|
15
|
+
return undefined;
|
|
16
|
+
return Object.getOwnPropertyDescriptor(value, key)?.value;
|
|
17
|
+
}
|
|
18
|
+
function normalizeOutcome(event) {
|
|
19
|
+
const envelope = event.context.toolOutput;
|
|
20
|
+
const result = field(envelope, 'result') ?? envelope;
|
|
21
|
+
const errorValue = field(envelope, 'error');
|
|
22
|
+
const resultExit = field(result, 'exitCode');
|
|
23
|
+
const detailsExit = field(field(result, 'details'), 'exitCode');
|
|
24
|
+
const exitCode = typeof resultExit === 'number' ? resultExit : typeof detailsExit === 'number' ? detailsExit : 0;
|
|
25
|
+
const error = errorValue === undefined || errorValue === null || errorValue === '' ? undefined : String(errorValue);
|
|
26
|
+
const durationValue = field(envelope, 'durationMs');
|
|
27
|
+
return {
|
|
28
|
+
failure: error !== undefined || exitCode !== 0,
|
|
29
|
+
exitCode,
|
|
30
|
+
...(error ? { error: error.slice(0, MAX_PREVIEW) } : {}),
|
|
31
|
+
...(typeof durationValue === 'number' && Number.isFinite(durationValue) && durationValue >= 0 ? { durationMs: durationValue } : {}),
|
|
32
|
+
params: event.context.toolInput ?? {},
|
|
33
|
+
result,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function isEvidence(value) {
|
|
37
|
+
return Array.isArray(value) && value.length <= 8 && value.every((entry) => isRecord(entry) && typeof field(entry, 'sourceRef') === 'string' && String(field(entry, 'sourceRef')).trim().length > 0
|
|
38
|
+
&& String(field(entry, 'sourceRef')).length <= 300 && typeof field(entry, 'note') === 'string' && String(field(entry, 'note')).length <= 200);
|
|
39
|
+
}
|
|
40
|
+
function parseEnrichment(value) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return {};
|
|
43
|
+
if (!isRecord(value))
|
|
44
|
+
return null;
|
|
45
|
+
const painScore = field(value, 'painScore');
|
|
46
|
+
const isRisky = field(value, 'isRisky');
|
|
47
|
+
const consecutiveErrors = field(value, 'consecutiveErrors');
|
|
48
|
+
const evidence = field(value, 'evidence');
|
|
49
|
+
const eventId = field(value, 'eventId');
|
|
50
|
+
const relativePath = field(value, 'relativePath');
|
|
51
|
+
const agentId = field(value, 'agentId');
|
|
52
|
+
const errorHash = field(value, 'errorHash');
|
|
53
|
+
if (painScore !== undefined && (typeof painScore !== 'number' || !Number.isFinite(painScore) || painScore < 0 || painScore > 100))
|
|
54
|
+
return null;
|
|
55
|
+
if (isRisky !== undefined && typeof isRisky !== 'boolean')
|
|
56
|
+
return null;
|
|
57
|
+
if (consecutiveErrors !== undefined && (typeof consecutiveErrors !== 'number' || !Number.isInteger(consecutiveErrors) || consecutiveErrors < 0))
|
|
58
|
+
return null;
|
|
59
|
+
for (const key of ['eventId', 'relativePath', 'agentId', 'errorHash']) {
|
|
60
|
+
const candidate = field(value, key);
|
|
61
|
+
if (candidate !== undefined && (typeof candidate !== 'string' || candidate.trim().length === 0 || candidate.length > 500))
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (evidence !== undefined && !isEvidence(evidence))
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
...(typeof eventId === 'string' ? { eventId } : {}),
|
|
68
|
+
...(typeof painScore === 'number' ? { painScore } : {}),
|
|
69
|
+
...(typeof isRisky === 'boolean' ? { isRisky } : {}),
|
|
70
|
+
...(typeof consecutiveErrors === 'number' ? { consecutiveErrors } : {}),
|
|
71
|
+
...(typeof relativePath === 'string' ? { relativePath } : {}),
|
|
72
|
+
...(typeof agentId === 'string' ? { agentId } : {}),
|
|
73
|
+
...(typeof errorHash === 'string' ? { errorHash } : {}),
|
|
74
|
+
...(isEvidence(evidence) ? { evidence } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function stable(value, seen = new WeakSet()) {
|
|
78
|
+
if (value === null || typeof value !== 'object') {
|
|
79
|
+
if (typeof value === 'string')
|
|
80
|
+
return JSON.stringify(value.slice(0, 2_000));
|
|
81
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
82
|
+
return JSON.stringify(value);
|
|
83
|
+
return JSON.stringify(String(value));
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(value))
|
|
86
|
+
return '"[circular]"';
|
|
87
|
+
seen.add(value);
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
return `[${value.slice(0, 50).map((item) => stable(item, seen)).join(',')}]`;
|
|
90
|
+
const keys = Object.keys(value).sort().slice(0, 50);
|
|
91
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stable(field(value, key), seen)}`).join(',')}}`;
|
|
92
|
+
}
|
|
93
|
+
function preview(value) {
|
|
94
|
+
try {
|
|
95
|
+
const text = stable(value);
|
|
96
|
+
return text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW - 3)}...` : text;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '[result_preview_unavailable]';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function ids(input) {
|
|
103
|
+
const { event, outcome, sanitizedParams, canonicalEventId } = input;
|
|
104
|
+
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,
|
|
110
|
+
suppliedEventId: canonicalEventId ?? null,
|
|
111
|
+
params: sanitizedParams,
|
|
112
|
+
result: sanitizeValue(outcome.result, 0, event.context.workspaceDir),
|
|
113
|
+
error: outcome.error ?? null,
|
|
114
|
+
exitCode: outcome.exitCode,
|
|
115
|
+
failure: outcome.failure,
|
|
116
|
+
});
|
|
117
|
+
const digest = createHash('sha256').update(canonical).digest('hex');
|
|
118
|
+
return { eventId: `host_${digest}`, painId: `pain_host_${digest}` };
|
|
119
|
+
}
|
|
120
|
+
const REQUIRED_COLUMNS = {
|
|
121
|
+
sessions: { session_id: 'TEXT', started_at: 'TEXT', updated_at: 'TEXT' },
|
|
122
|
+
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' },
|
|
124
|
+
};
|
|
125
|
+
function pragmaField(row, key) {
|
|
126
|
+
return isRecord(row) && Object.hasOwn(row, key) ? Object.getOwnPropertyDescriptor(row, key)?.value : undefined;
|
|
127
|
+
}
|
|
128
|
+
function hasCanonicalIndexPredicate(db) {
|
|
129
|
+
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?").get('idx_pain_events_canonical_pain_id');
|
|
130
|
+
const sql = pragmaField(row, 'sql');
|
|
131
|
+
if (typeof sql !== 'string' || sql.length === 0 || sql.length > 2_000)
|
|
132
|
+
return false;
|
|
133
|
+
const normalized = sql
|
|
134
|
+
.replace(/["'`]/g, '')
|
|
135
|
+
.replace(/\[|\]/g, '')
|
|
136
|
+
.replace(/\s+/g, ' ')
|
|
137
|
+
.trim()
|
|
138
|
+
.replace(/;$/, '')
|
|
139
|
+
.toUpperCase();
|
|
140
|
+
const whereIndex = normalized.indexOf(' WHERE ');
|
|
141
|
+
return whereIndex >= 0 && normalized.slice(whereIndex + 7) === 'CANONICAL_PAIN_ID IS NOT NULL';
|
|
142
|
+
}
|
|
143
|
+
function hasCanonicalSchema(db) {
|
|
144
|
+
for (const [table, required] of Object.entries(REQUIRED_COLUMNS)) {
|
|
145
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
146
|
+
const actual = new Map();
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const name = pragmaField(row, 'name');
|
|
149
|
+
const type = pragmaField(row, 'type');
|
|
150
|
+
if (typeof name !== 'string' || typeof type !== 'string')
|
|
151
|
+
return false;
|
|
152
|
+
actual.set(name, type.toUpperCase());
|
|
153
|
+
}
|
|
154
|
+
for (const [name, type] of Object.entries(required)) {
|
|
155
|
+
if (actual.get(name) !== type)
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const indexes = db.prepare('PRAGMA index_list(pain_events)').all();
|
|
160
|
+
const canonical = indexes.find((row) => pragmaField(row, 'name') === 'idx_pain_events_canonical_pain_id');
|
|
161
|
+
if (pragmaField(canonical, 'unique') !== 1 || pragmaField(canonical, 'partial') !== 1)
|
|
162
|
+
return false;
|
|
163
|
+
if (!hasCanonicalIndexPredicate(db))
|
|
164
|
+
return false;
|
|
165
|
+
const indexColumns = db.prepare('PRAGMA index_info(idx_pain_events_canonical_pain_id)').all();
|
|
166
|
+
if (indexColumns.length !== 1 || pragmaField(indexColumns[0], 'name') !== 'canonical_pain_id')
|
|
167
|
+
return false;
|
|
168
|
+
// Preparing the exact production statements proves syntax/column readiness
|
|
169
|
+
// without executing mutation or invoking host enrichment side effects.
|
|
170
|
+
db.prepare('SELECT 1 FROM tool_calls WHERE session_id = ? AND tool_name = ? AND params_json = ? AND outcome = ? AND exit_code IS ? AND error_message IS ? AND result_preview IS ?');
|
|
171
|
+
db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?');
|
|
172
|
+
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
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
export function createProductionPainEvidenceHandler(options = {}) {
|
|
178
|
+
return async (event) => {
|
|
179
|
+
const dbPath = path.join(event.context.workspaceDir, '.state', 'trajectory.db');
|
|
180
|
+
if (!fs.existsSync(dbPath)) {
|
|
181
|
+
return { decision: 'observe', source: event.source, warnings: ['trajectory_db_not_found'], metadata: { outcome: 'unavailable', admitted: false, duplicate: false, nextAction: 'initialize the selected PD workspace before retrying the hook' } };
|
|
182
|
+
}
|
|
183
|
+
let db;
|
|
184
|
+
try {
|
|
185
|
+
db = options.painDatabaseFactory ? options.painDatabaseFactory(dbPath) : new Database(dbPath);
|
|
186
|
+
db.pragma('busy_timeout = 5000');
|
|
187
|
+
if (!hasCanonicalSchema(db)) {
|
|
188
|
+
db.close();
|
|
189
|
+
db = undefined;
|
|
190
|
+
return { decision: 'observe', source: event.source, warnings: ['trajectory_schema_invalid'], metadata: { outcome: 'unavailable', admitted: false, duplicate: false, nextAction: 'run the supported PD workspace migration' } };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
try {
|
|
195
|
+
db?.close();
|
|
196
|
+
}
|
|
197
|
+
catch { /* best-effort cleanup of an unusable handle */ }
|
|
198
|
+
return { decision: 'observe', source: event.source, warnings: [`trajectory_database_unavailable:${error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200)}`], metadata: { outcome: 'unavailable', admitted: false, duplicate: false, nextAction: 'inspect or repair the selected PD trajectory database' } };
|
|
199
|
+
}
|
|
200
|
+
const warnings = [];
|
|
201
|
+
let enrichment;
|
|
202
|
+
try {
|
|
203
|
+
enrichment = parseEnrichment(await options.painEnrichmentProvider?.(event));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
warnings.push(`pain_enrichment_failed:${error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200)}`);
|
|
207
|
+
enrichment = null;
|
|
208
|
+
}
|
|
209
|
+
if (!enrichment) {
|
|
210
|
+
try {
|
|
211
|
+
db.close();
|
|
212
|
+
}
|
|
213
|
+
catch { /* no business write occurred */ }
|
|
214
|
+
return { decision: 'observe', source: event.source, warnings: warnings.length > 0 ? warnings : ['pain_enrichment_invalid'], metadata: { outcome: 'unavailable', admitted: false, duplicate: false, nextAction: 'inspect host pain enrichment input' } };
|
|
215
|
+
}
|
|
216
|
+
const outcome = normalizeOutcome(event);
|
|
217
|
+
const sanitizedParams = sanitizeToolParams(outcome.params, event.context.workspaceDir);
|
|
218
|
+
const toolName = event.context.toolName ?? '';
|
|
219
|
+
const sourceObservation = buildToolFailureObservation({ toolName, error: outcome.error, exitCode: outcome.exitCode });
|
|
220
|
+
const sourceKind = resolveSourceKind({
|
|
221
|
+
observedAt: new Date().toISOString(), workspaceId: event.context.workspaceDir,
|
|
222
|
+
sessionId: event.context.sessionId, toolName, failureSource: sourceObservation.failureSource,
|
|
223
|
+
toolNotFound: sourceObservation.toolNotFound, nonZeroExit: outcome.exitCode !== 0,
|
|
224
|
+
});
|
|
225
|
+
const relativePath = enrichment.relativePath ?? String(field(outcome.params, 'file_path') ?? field(outcome.params, 'path') ?? 'unknown').slice(0, 500);
|
|
226
|
+
const isRisky = enrichment.isRisky ?? (path.isAbsolute(relativePath) && !path.resolve(relativePath).startsWith(`${path.resolve(event.context.workspaceDir)}${path.sep}`));
|
|
227
|
+
const painScore = enrichment.painScore ?? Math.min(100, (outcome.exitCode !== 0 ? 70 : 0) + (isRisky ? 20 : 0));
|
|
228
|
+
const errorHash = enrichment.errorHash ?? createHash('sha256').update(outcome.error ?? String(outcome.exitCode)).digest('hex');
|
|
229
|
+
const cooldownKey = `${path.resolve(event.context.workspaceDir)}:${event.context.sessionId}:${sourceObservation.failureSource}:${errorHash}`;
|
|
230
|
+
const last = cooldowns.get(cooldownKey);
|
|
231
|
+
const cooldownActive = last !== undefined && Date.now() - last < PAIN_COOLDOWN_WINDOW_MS;
|
|
232
|
+
const triage = evaluateTriage({ sourceKind, score: painScore, consecutiveErrors: enrichment.consecutiveErrors, isRisky });
|
|
233
|
+
const trigger = evaluateTriggerController({ triageResult: triage, isOwnerManual: false, isCooldownActive: cooldownActive, isValid: true, score: painScore, sessionId: event.context.sessionId });
|
|
234
|
+
const admitted = outcome.failure && WRITE_TOOLS.has(toolName) && trigger.shouldCreateDiagnosticTask;
|
|
235
|
+
const { eventId, painId } = ids({ event, outcome, sanitizedParams, ...(enrichment.eventId ? { canonicalEventId: enrichment.eventId } : {}) });
|
|
236
|
+
const createdAt = new Date().toISOString();
|
|
237
|
+
const paramsJson = stable(sanitizedParams);
|
|
238
|
+
const resultPreview = preview({ eventId, result: sanitizeValue(outcome.result, 0, event.context.workspaceDir) });
|
|
239
|
+
let duplicate = false;
|
|
240
|
+
let duplicateAdmitted = false;
|
|
241
|
+
try {
|
|
242
|
+
db.transaction(() => {
|
|
243
|
+
if (db.prepare('SELECT 1 FROM tool_calls WHERE session_id = ? AND tool_name = ? AND params_json = ? AND outcome = ? AND exit_code IS ? AND error_message IS ? AND result_preview IS ?').get(event.context.sessionId, toolName, paramsJson, outcome.failure ? 'failure' : 'success', outcome.exitCode, outcome.error ?? null, resultPreview)) {
|
|
244
|
+
duplicate = true;
|
|
245
|
+
duplicateAdmitted = db.prepare('SELECT 1 FROM pain_events WHERE canonical_pain_id = ?').get(painId) !== undefined;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
db.prepare(`INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at`).run(event.context.sessionId, createdAt, createdAt);
|
|
249
|
+
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
|
+
.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
|
+
if (admitted) {
|
|
252
|
+
const reason = `Tool ${toolName} failed on ${relativePath}; diagnosticGate=${trigger.reason}`;
|
|
253
|
+
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', 1, enrichment.evidence?.map((entry) => `${entry.sourceRef}: ${entry.note}`).join('\n') ?? null, painId, null, createdAt);
|
|
255
|
+
}
|
|
256
|
+
})();
|
|
257
|
+
if (admitted && !duplicate) {
|
|
258
|
+
const admittedAt = Date.now();
|
|
259
|
+
// Bound the module-level cooldown map in long-lived host processes
|
|
260
|
+
// (OpenClaw): keys carry sessionId + errorHash, so unbounded retention
|
|
261
|
+
// grows monotonically. Evict entries outside the cooldown window —
|
|
262
|
+
// they can never be consulted again.
|
|
263
|
+
for (const [staleKey, staleAt] of cooldowns) {
|
|
264
|
+
if (admittedAt - staleAt >= PAIN_COOLDOWN_WINDOW_MS)
|
|
265
|
+
cooldowns.delete(staleKey);
|
|
266
|
+
}
|
|
267
|
+
cooldowns.set(cooldownKey, admittedAt);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return { decision: 'observe', source: event.source, warnings: [`trajectory_write_failed:${error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200)}`], metadata: { outcome: outcome.failure ? 'failure' : 'success', admitted: false, duplicate: false, nextAction: 'inspect the workspace trajectory database and retry' } };
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
try {
|
|
275
|
+
db.close();
|
|
276
|
+
}
|
|
277
|
+
catch { /* write result already determined; cleanup is best-effort */ }
|
|
278
|
+
}
|
|
279
|
+
const effectiveAdmitted = admitted || duplicateAdmitted;
|
|
280
|
+
return { decision: 'observe', source: event.source, metadata: {
|
|
281
|
+
eventId, painId: effectiveAdmitted ? painId : null, outcome: outcome.failure ? 'failure' : 'success', admitted: effectiveAdmitted, duplicate,
|
|
282
|
+
sourceKind, failureSource: sourceObservation.failureSource ?? null, triggerOutcome: trigger.outcome,
|
|
283
|
+
triggerReason: trigger.reason, painScore, isRisky, relativePath, agentId: enrichment.agentId ?? null,
|
|
284
|
+
evidence: enrichment.evidence ?? [],
|
|
285
|
+
} };
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export function resetProductionPainCooldownForTest() {
|
|
289
|
+
cooldowns.clear();
|
|
290
|
+
}
|
|
291
|
+
export function productionPainCooldownEntryCountForTest() {
|
|
292
|
+
return cooldowns.size;
|
|
293
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HostEvent, HostEventResult } from '@principles/core/host';
|
|
2
|
+
import { type RuleImplementationRuntime } from './rule-implementation-runtime.js';
|
|
3
|
+
export interface ProductionRuleContextRequest {
|
|
4
|
+
workspaceDir: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
targetPath: string;
|
|
7
|
+
toolName: string;
|
|
8
|
+
rawPayload: unknown;
|
|
9
|
+
}
|
|
10
|
+
export type RuleContextProvider = (request: ProductionRuleContextRequest) => unknown | Promise<unknown>;
|
|
11
|
+
export interface RuleInputEnrichment {
|
|
12
|
+
currentGfi: number;
|
|
13
|
+
recentThinking: boolean;
|
|
14
|
+
epTier: number;
|
|
15
|
+
bashRisk: 'safe' | 'normal' | 'dangerous' | 'unknown';
|
|
16
|
+
}
|
|
17
|
+
export type RuleInputEnrichmentProvider = (request: ProductionRuleContextRequest) => unknown | Promise<unknown>;
|
|
18
|
+
export interface ProductionRuleHostGateOptions {
|
|
19
|
+
ruleContextProvider?: RuleContextProvider;
|
|
20
|
+
ruleInputEnrichmentProvider?: RuleInputEnrichmentProvider;
|
|
21
|
+
implementationRuntime?: RuleImplementationRuntime;
|
|
22
|
+
}
|
|
23
|
+
export declare function createProductionRuleHostGate(options?: ProductionRuleHostGateOptions): (event: HostEvent) => Promise<HostEventResult>;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildRuleHostAction, estimateLineChanges, mergeDecisions, SqliteConnection, UNAVAILABLE_RULE_CONTEXT, validateRuleContextV2, validateRuleHostResult, } from '@principles/core/runtime-v2';
|
|
4
|
+
import { createNodeRuleImplementationRuntime, MAX_ACTIVE_RULES, RULE_BATCH_SOURCE_BYTES, RULE_SOURCE_BYTES, } from './rule-implementation-runtime.js';
|
|
5
|
+
const WARNING_LIMIT = 500;
|
|
6
|
+
const MAX_WARNINGS = 16;
|
|
7
|
+
const GATE_DEADLINE_MS = 3_000;
|
|
8
|
+
const ARTIFACT_CONTENT_BYTES = 512 * 1024;
|
|
9
|
+
function boundedWarning(reason, nextAction) {
|
|
10
|
+
return `${reason}; nextAction=${nextAction}`.replace(/\s+/g, ' ').slice(0, WARNING_LIMIT);
|
|
11
|
+
}
|
|
12
|
+
function addWarning(warnings, reason, nextAction) {
|
|
13
|
+
if (warnings.length < MAX_WARNINGS)
|
|
14
|
+
warnings.push(boundedWarning(reason, nextAction));
|
|
15
|
+
}
|
|
16
|
+
function remainingGateMs(startedAt) {
|
|
17
|
+
return GATE_DEADLINE_MS - (Date.now() - startedAt);
|
|
18
|
+
}
|
|
19
|
+
function createDeadlinePromise(remaining) {
|
|
20
|
+
let timer;
|
|
21
|
+
const promise = new Promise((_resolve, reject) => {
|
|
22
|
+
timer = setTimeout(() => reject(new Error('gate_deadline_exceeded')), remaining);
|
|
23
|
+
timer.unref?.();
|
|
24
|
+
});
|
|
25
|
+
return { promise, clear: () => { if (timer !== undefined)
|
|
26
|
+
clearTimeout(timer); } };
|
|
27
|
+
}
|
|
28
|
+
async function withinGateDeadline(value, startedAt) {
|
|
29
|
+
const remaining = remainingGateMs(startedAt);
|
|
30
|
+
if (remaining <= 0)
|
|
31
|
+
throw new Error('gate_deadline_exceeded');
|
|
32
|
+
const deadline = createDeadlinePromise(remaining);
|
|
33
|
+
try {
|
|
34
|
+
return await Promise.race([Promise.resolve(value), deadline.promise]);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
deadline.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
function readToolInput(rawPayload) {
|
|
44
|
+
if (!isRecord(rawPayload) || !Object.hasOwn(rawPayload, 'toolInput'))
|
|
45
|
+
return null;
|
|
46
|
+
const input = rawPayload.toolInput;
|
|
47
|
+
if (!isRecord(input) || typeof input.toolName !== 'string' || input.toolName.trim().length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
const { params } = input;
|
|
50
|
+
if (!isRecord(params))
|
|
51
|
+
return null;
|
|
52
|
+
return { toolName: input.toolName, params };
|
|
53
|
+
}
|
|
54
|
+
function isRuleMeta(value) {
|
|
55
|
+
return isRecord(value) && typeof value.name === 'string' && typeof value.version === 'string'
|
|
56
|
+
&& typeof value.ruleId === 'string' && typeof value.coversCondition === 'string';
|
|
57
|
+
}
|
|
58
|
+
function isRuleContext(value) {
|
|
59
|
+
return validateRuleContextV2(value).valid;
|
|
60
|
+
}
|
|
61
|
+
function isRuleResult(value) {
|
|
62
|
+
return validateRuleHostResult(value).valid;
|
|
63
|
+
}
|
|
64
|
+
export function createProductionRuleHostGate(options = {}) {
|
|
65
|
+
const implementationRuntime = options.implementationRuntime ?? createNodeRuleImplementationRuntime();
|
|
66
|
+
return async (event) => {
|
|
67
|
+
const startedAt = Date.now();
|
|
68
|
+
const warnings = [];
|
|
69
|
+
const input = readToolInput(event.rawPayload);
|
|
70
|
+
if (!input || input.toolName !== event.context.toolName) {
|
|
71
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning('tool_input_invalid', 'decode and validate toolName and params before dispatch')] };
|
|
72
|
+
}
|
|
73
|
+
const isBash = new Set(['bash', 'exec', 'execute', 'run_shell_command']).has(input.toolName);
|
|
74
|
+
const isWrite = new Set(['write', 'write_file', 'edit', 'edit_file', 'replace', 'apply_patch']).has(input.toolName);
|
|
75
|
+
const action = buildRuleHostAction(input.toolName, input.params, event.context.workspaceDir, { isBashTool: isBash, isWriteTool: isWrite });
|
|
76
|
+
if (action.normalizedPath === null)
|
|
77
|
+
return { decision: 'allow', source: event.source, metadata: { evaluatedLiveRules: 0 } };
|
|
78
|
+
let context;
|
|
79
|
+
let enrichment = { currentGfi: 0, recentThinking: false, epTier: 0, bashRisk: 'unknown' };
|
|
80
|
+
const request = {
|
|
81
|
+
workspaceDir: event.context.workspaceDir, sessionId: event.context.sessionId,
|
|
82
|
+
targetPath: action.normalizedPath, toolName: input.toolName, rawPayload: event.rawPayload,
|
|
83
|
+
};
|
|
84
|
+
if (options.ruleInputEnrichmentProvider) {
|
|
85
|
+
try {
|
|
86
|
+
const candidate = await withinGateDeadline(options.ruleInputEnrichmentProvider(request), startedAt);
|
|
87
|
+
if (isRecord(candidate) && typeof candidate.currentGfi === 'number' && Number.isFinite(candidate.currentGfi)
|
|
88
|
+
&& typeof candidate.recentThinking === 'boolean' && typeof candidate.epTier === 'number' && Number.isFinite(candidate.epTier)
|
|
89
|
+
&& (candidate.bashRisk === 'safe' || candidate.bashRisk === 'normal' || candidate.bashRisk === 'dangerous' || candidate.bashRisk === 'unknown')) {
|
|
90
|
+
enrichment = { currentGfi: candidate.currentGfi, recentThinking: candidate.recentThinking, epTier: candidate.epTier, bashRisk: candidate.bashRisk };
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
addWarning(warnings, 'rule_input_enrichment_invalid', 'repair the host enrichment provider');
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
addWarning(warnings, `rule_input_enrichment_failed: ${error instanceof Error ? error.message : String(error)}`, 'inspect the host enrichment provider');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (options.ruleContextProvider) {
|
|
100
|
+
try {
|
|
101
|
+
const candidate = await withinGateDeadline(options.ruleContextProvider(request), startedAt);
|
|
102
|
+
if (candidate === undefined) {
|
|
103
|
+
context = undefined;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const validation = validateRuleContextV2(candidate);
|
|
107
|
+
if (isRuleContext(candidate))
|
|
108
|
+
context = candidate;
|
|
109
|
+
else {
|
|
110
|
+
context = UNAVAILABLE_RULE_CONTEXT;
|
|
111
|
+
addWarning(warnings, `rule_context_invalid: ${validation.errors.join('; ')}`, 'repair the host context provider');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
context = UNAVAILABLE_RULE_CONTEXT;
|
|
117
|
+
addWarning(warnings, `rule_context_provider_failed: ${error instanceof Error ? error.message : String(error)}`, 'inspect the host context provider and retry');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const dbPath = path.join(event.context.workspaceDir, '.pd', 'state.db');
|
|
121
|
+
if (remainingGateMs(startedAt) <= 0) {
|
|
122
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning('gate_deadline_exceeded', 'inspect host context providers and active RuleCode resource use')] };
|
|
123
|
+
}
|
|
124
|
+
if (!fs.existsSync(dbPath)) {
|
|
125
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning('activation_db_not_found', 'initialize_workspace_runtime_state')] };
|
|
126
|
+
}
|
|
127
|
+
const connection = new SqliteConnection({ workspaceDir: event.context.workspaceDir, readonly: true, bootstrapIfMissing: false });
|
|
128
|
+
const candidates = [];
|
|
129
|
+
try {
|
|
130
|
+
const rows = connection.getDb().prepare(`
|
|
131
|
+
SELECT a.activation_id, a.artifact_id, a.target_ref, a.action,
|
|
132
|
+
p.source_rule_id, p.source_principle_id,
|
|
133
|
+
length(CAST(p.content_json AS BLOB)) AS content_bytes
|
|
134
|
+
FROM activations a JOIN pi_artifacts p ON a.artifact_id = p.artifact_id
|
|
135
|
+
WHERE a.channel = 'code_tool_hook' AND a.deactivated_at IS NULL
|
|
136
|
+
ORDER BY a.activated_at ASC
|
|
137
|
+
LIMIT ?
|
|
138
|
+
`).all(MAX_ACTIVE_RULES + 1);
|
|
139
|
+
if (!Array.isArray(rows)) {
|
|
140
|
+
addWarning(warnings, 'activation_query_invalid', 'inspect state.db schema and integrity');
|
|
141
|
+
}
|
|
142
|
+
else if (rows.length > MAX_ACTIVE_RULES) {
|
|
143
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`active_rule_limit_exceeded: maximum ${MAX_ACTIVE_RULES}`, 'deactivate excess active RuleHost rules and retry')], metadata: { evaluatedLiveRules: 0 } };
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
if (!isRecord(row) || typeof row.content_bytes !== 'number' || !Number.isSafeInteger(row.content_bytes) || row.content_bytes < 0) {
|
|
148
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning('artifact_content_size_invalid', 'inspect state.db artifact content integrity')], metadata: { evaluatedLiveRules: 0 } };
|
|
149
|
+
}
|
|
150
|
+
if (row.content_bytes > ARTIFACT_CONTENT_BYTES) {
|
|
151
|
+
const activation = typeof row.activation_id === 'string' ? row.activation_id : 'unknown';
|
|
152
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`artifact_content_budget_exceeded: activation=${activation} bytes=${row.content_bytes} maximum=${ARTIFACT_CONTENT_BYTES}`, 'reduce the active artifact envelope and reactivate the rule')], metadata: { evaluatedLiveRules: 0 } };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const groups = new Map();
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
if (!isRecord(row) || typeof row.target_ref !== 'string' || row.target_ref.length === 0) {
|
|
158
|
+
addWarning(warnings, 'activation_row_invalid', 'deactivate and recreate the malformed activation');
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const group = groups.get(row.target_ref) ?? [];
|
|
162
|
+
group.push(row);
|
|
163
|
+
groups.set(row.target_ref, group);
|
|
164
|
+
}
|
|
165
|
+
for (const [targetRef, group] of groups) {
|
|
166
|
+
if (group.length !== 1) {
|
|
167
|
+
addWarning(warnings, `duplicate_active_activation: ${targetRef}`, 'deactivate all but one activation for this target_ref');
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const [row] = group;
|
|
171
|
+
if (!row || row.action !== 'code_tool_hook_live_activate')
|
|
172
|
+
continue;
|
|
173
|
+
const activationId = row.activation_id;
|
|
174
|
+
const artifactId = row.artifact_id;
|
|
175
|
+
const expectedContentBytes = row.content_bytes;
|
|
176
|
+
if (typeof activationId !== 'string' || typeof artifactId !== 'string' || typeof expectedContentBytes !== 'number') {
|
|
177
|
+
addWarning(warnings, 'activation_required_fields_invalid', 'deactivate and recreate the activation');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const contentRow = connection.getDb().prepare(`
|
|
181
|
+
SELECT content_json, length(CAST(content_json AS BLOB)) AS content_bytes
|
|
182
|
+
FROM pi_artifacts WHERE artifact_id = ?
|
|
183
|
+
`).get(artifactId);
|
|
184
|
+
if (!isRecord(contentRow) || typeof contentRow.content_json !== 'string'
|
|
185
|
+
|| typeof contentRow.content_bytes !== 'number' || !Number.isSafeInteger(contentRow.content_bytes)) {
|
|
186
|
+
addWarning(warnings, `activation_artifact_invalid: ${activationId}`, 'inspect state.db artifact content integrity');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const contentJson = contentRow.content_json;
|
|
190
|
+
const returnedContentBytes = Buffer.byteLength(contentJson, 'utf8');
|
|
191
|
+
if (contentRow.content_bytes > ARTIFACT_CONTENT_BYTES || returnedContentBytes > ARTIFACT_CONTENT_BYTES) {
|
|
192
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`artifact_content_budget_exceeded: activation=${activationId} bytes=${Math.max(contentRow.content_bytes, returnedContentBytes)} maximum=${ARTIFACT_CONTENT_BYTES}`, 'reduce the active artifact envelope and reactivate the rule')], metadata: { evaluatedLiveRules: 0 } };
|
|
193
|
+
}
|
|
194
|
+
if (contentRow.content_bytes !== expectedContentBytes || returnedContentBytes !== contentRow.content_bytes) {
|
|
195
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`artifact_content_size_changed: activation=${activationId}`, 'retry after the active artifact update completes')], metadata: { evaluatedLiveRules: 0 } };
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const content = JSON.parse(contentJson);
|
|
199
|
+
if (!isRecord(content) || typeof content.implementationCode !== 'string' || content.implementationCode.length === 0) {
|
|
200
|
+
addWarning(warnings, `activation_artifact_invalid: ${activationId}`, 'regenerate the rule artifact with implementationCode');
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const sourceBytes = Buffer.byteLength(content.implementationCode, 'utf8');
|
|
204
|
+
if (sourceBytes > RULE_SOURCE_BYTES) {
|
|
205
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_source_budget_exceeded: activation=${activationId} bytes=${sourceBytes}`, `reduce each RuleCode source below ${RULE_SOURCE_BYTES} bytes`)], metadata: { evaluatedLiveRules: 0 } };
|
|
206
|
+
}
|
|
207
|
+
if (Object.hasOwn(content, 'requiresContextVersion')) {
|
|
208
|
+
if (content.requiresContextVersion !== 2) {
|
|
209
|
+
addWarning(warnings, `unsupported_context_version: ${String(content.requiresContextVersion)}`, 'regenerate the rule artifact for context version 2');
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!context) {
|
|
213
|
+
addWarning(warnings, 'rule_context_v2_unavailable', 'enable and wire the host rule context provider');
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const ruleId = typeof content.ruleId === 'string' ? content.ruleId : typeof row.source_rule_id === 'string' ? row.source_rule_id : artifactId;
|
|
218
|
+
const principleId = typeof content.principleId === 'string' ? content.principleId : typeof row.source_principle_id === 'string' ? row.source_principle_id : ruleId;
|
|
219
|
+
const fallbackMeta = { name: activationId, version: '1', ruleId, coversCondition: 'all' };
|
|
220
|
+
candidates.push({ implId: activationId, ruleId, principleId, meta: isRuleMeta(content.meta) ? content.meta : fallbackMeta, source: content.implementationCode });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
addWarning(warnings, `implementation_unhealthy: ${error instanceof Error ? error.message : String(error)}`, 'fix the RuleCode and reactivate the rule');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const hostInput = {
|
|
228
|
+
action,
|
|
229
|
+
workspace: { isRiskPath: false, planStatus: 'NONE', hasPlanFile: false },
|
|
230
|
+
session: { sessionId: event.context.sessionId, currentGfi: enrichment.currentGfi, recentThinking: enrichment.recentThinking },
|
|
231
|
+
evolution: { epTier: enrichment.epTier },
|
|
232
|
+
derived: { estimatedLineChanges: estimateLineChanges({ toolName: input.toolName, params: input.params }), bashRisk: enrichment.bashRisk },
|
|
233
|
+
...(context ? { context } : {}),
|
|
234
|
+
};
|
|
235
|
+
const batchSourceBytes = candidates.reduce((sum, candidate) => sum + Buffer.byteLength(candidate.source, 'utf8'), 0);
|
|
236
|
+
if (batchSourceBytes > RULE_BATCH_SOURCE_BYTES) {
|
|
237
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_source_budget_exceeded: batchBytes=${batchSourceBytes}`, `reduce total active RuleCode below ${RULE_BATCH_SOURCE_BYTES} bytes`)], metadata: { evaluatedLiveRules: 0 } };
|
|
238
|
+
}
|
|
239
|
+
const batchSources = candidates.map((candidate) => ({ source: candidate.source, filename: `activation-${candidate.implId}` }));
|
|
240
|
+
const remaining = remainingGateMs(startedAt);
|
|
241
|
+
if (remaining <= 0) {
|
|
242
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning('gate_deadline_exceeded', 'reduce active RuleCode count or source size and retry')], metadata: { evaluatedLiveRules: 0 } };
|
|
243
|
+
}
|
|
244
|
+
const batch = implementationRuntime.evaluateBatch(batchSources, hostInput, remaining);
|
|
245
|
+
if (!batch.ok || !batch.results) {
|
|
246
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`${batch.reason ?? 'rule_batch_failed'}: ${batch.detail ?? 'unknown failure'}`, 'inspect active RuleCode resource use and repair or deactivate the unhealthy rule')], metadata: { evaluatedLiveRules: 0 } };
|
|
247
|
+
}
|
|
248
|
+
const timedOutChild = batch.results.find((candidate) => !candidate.ok && candidate.error?.includes('timed out'));
|
|
249
|
+
if (timedOutChild) {
|
|
250
|
+
return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_batch_timeout: ${timedOutChild.error ?? 'unknown child timeout'}`, 'fix or deactivate the unhealthy RuleCode and retry')], metadata: { evaluatedLiveRules: 0 } };
|
|
251
|
+
}
|
|
252
|
+
const implementations = [];
|
|
253
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
254
|
+
const candidate = candidates[index];
|
|
255
|
+
const batchResult = batch.results[index];
|
|
256
|
+
if (!candidate || !batchResult) {
|
|
257
|
+
addWarning(warnings, 'rule_batch_result_missing', 'inspect the RuleCode runtime result contract');
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!batchResult.ok) {
|
|
261
|
+
addWarning(warnings, `implementation_unhealthy: ${batchResult.error ?? 'unknown child error'}`, 'fix the RuleCode and reactivate the rule');
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const validation = validateRuleHostResult(batchResult.result);
|
|
265
|
+
if (!isRuleResult(batchResult.result)) {
|
|
266
|
+
addWarning(warnings, `invalid RuleHostResult: ${validation.errors.join('; ')}`, 'fix the RuleCode result and reactivate the rule');
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const validatedResult = batchResult.result.matched
|
|
270
|
+
? { ...batchResult.result, ruleId: candidate.ruleId, principleId: candidate.principleId }
|
|
271
|
+
: batchResult.result;
|
|
272
|
+
implementations.push({ ...candidate, evaluate: () => validatedResult });
|
|
273
|
+
}
|
|
274
|
+
const result = mergeDecisions(implementations, hostInput, {
|
|
275
|
+
warn(message) { addWarning(warnings, message, 'inspect the unhealthy activation and RuleCode output'); },
|
|
276
|
+
});
|
|
277
|
+
if (result?.decision === 'block') {
|
|
278
|
+
if (result.reason.trim().length === 0) {
|
|
279
|
+
addWarning(warnings, 'deny_reason_missing', 'fix the RuleCode to return a non-empty block reason');
|
|
280
|
+
return { decision: 'allow', source: event.source, warnings, metadata: { evaluatedLiveRules: implementations.length } };
|
|
281
|
+
}
|
|
282
|
+
return { decision: 'deny', reason: result.reason, source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleId: result.ruleId, principleId: result.principleId } };
|
|
283
|
+
}
|
|
284
|
+
return { decision: 'allow', source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleDecision: result?.decision ?? 'allow' } };
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
addWarning(warnings, `activation_read_failed: ${error instanceof Error ? error.message : String(error)}`, 'inspect state.db schema and integrity');
|
|
288
|
+
return { decision: 'allow', source: event.source, warnings, metadata: { evaluatedLiveRules: 0 } };
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
connection.close();
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const RULE_BATCH_TIMEOUT_MS = 2500;
|
|
2
|
+
export declare const RULE_BATCH_OUTPUT_BYTES: number;
|
|
3
|
+
export declare const RULE_SOURCE_BYTES: number;
|
|
4
|
+
export declare const RULE_BATCH_SOURCE_BYTES: number;
|
|
5
|
+
export declare const MAX_ACTIVE_RULES = 32;
|
|
6
|
+
export interface RuleBatchSource {
|
|
7
|
+
source: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RuleBatchResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
result?: unknown;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export type RuleBatchFailureReason = 'rule_batch_timeout' | 'rule_batch_output_exceeded' | 'rule_batch_failed';
|
|
16
|
+
export interface RuleBatchEvaluation {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
results?: readonly RuleBatchResult[];
|
|
19
|
+
reason?: RuleBatchFailureReason;
|
|
20
|
+
detail?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RuleImplementationRuntime {
|
|
23
|
+
evaluateBatch(rules: readonly RuleBatchSource[], input: unknown, timeoutMs: number): RuleBatchEvaluation;
|
|
24
|
+
}
|
|
25
|
+
export declare function createNodeRuleImplementationRuntime(): RuleImplementationRuntime;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
export const RULE_BATCH_TIMEOUT_MS = 2_500;
|
|
3
|
+
export const RULE_BATCH_OUTPUT_BYTES = 64 * 1024;
|
|
4
|
+
export const RULE_SOURCE_BYTES = 64 * 1024;
|
|
5
|
+
export const RULE_BATCH_SOURCE_BYTES = 256 * 1024;
|
|
6
|
+
export const MAX_ACTIVE_RULES = 32;
|
|
7
|
+
const EVALUATION_PROCESS_SOURCE = String.raw `
|
|
8
|
+
const vm = require('node:vm');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
function normalize(sourceCode) {
|
|
11
|
+
const withoutExports = sourceCode
|
|
12
|
+
.replace(/export\s+const\s+meta\s*=/, 'const meta =')
|
|
13
|
+
.replace(/export\s+function\s+evaluate\s*\(/, 'function evaluate(');
|
|
14
|
+
return withoutExports + '\nglobalThis.__pdRuleModule = { meta: typeof meta === "undefined" ? undefined : meta, evaluate: typeof evaluate === "undefined" ? undefined : evaluate };';
|
|
15
|
+
}
|
|
16
|
+
function preview(error) { return String(error).slice(0, 500); }
|
|
17
|
+
try {
|
|
18
|
+
const workerData = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const rule of workerData.rules) {
|
|
21
|
+
try {
|
|
22
|
+
const context = vm.createContext(Object.create(null));
|
|
23
|
+
new vm.Script(normalize(rule.source), { filename: rule.filename }).runInContext(context, { timeout: 1000 });
|
|
24
|
+
if (!context.__pdRuleModule || typeof context.__pdRuleModule.evaluate !== 'function') throw new Error('compiled module has no evaluate function');
|
|
25
|
+
// Sandbox boundary: hand the context ONLY a primitive JSON string and
|
|
26
|
+
// build the call input + helpers with the context's own intrinsics.
|
|
27
|
+
// Passing host-realm objects (the parsed input, a host-built helpers
|
|
28
|
+
// object) would let rule source walk .constructor.constructor back to
|
|
29
|
+
// the host realm's Function constructor and reach the child process
|
|
30
|
+
// and require — a full sandbox escape with user privileges.
|
|
31
|
+
context.__pdInputJson = JSON.stringify(workerData.input);
|
|
32
|
+
new vm.Script(
|
|
33
|
+
'const __pdCallInput = JSON.parse(globalThis.__pdInputJson);' +
|
|
34
|
+
'delete globalThis.__pdInputJson;' +
|
|
35
|
+
'const __pdCallHelpers = Object.freeze({' +
|
|
36
|
+
' isRiskPath: () => __pdCallInput.workspace.isRiskPath,' +
|
|
37
|
+
' getToolName: () => __pdCallInput.action.toolName,' +
|
|
38
|
+
' getEstimatedLineChanges: () => __pdCallInput.derived.estimatedLineChanges,' +
|
|
39
|
+
' getBashRisk: () => __pdCallInput.derived.bashRisk,' +
|
|
40
|
+
' hasPlanFile: () => __pdCallInput.workspace.hasPlanFile,' +
|
|
41
|
+
' getPlanStatus: () => __pdCallInput.workspace.planStatus,' +
|
|
42
|
+
' getEpTier: () => __pdCallInput.evolution.epTier' +
|
|
43
|
+
'});', { filename: rule.filename + '.bootstrap' }
|
|
44
|
+
).runInContext(context, { timeout: 1000 });
|
|
45
|
+
const result = new vm.Script('__pdRuleModule.evaluate(__pdCallInput, __pdCallHelpers)', { filename: rule.filename + '.call' })
|
|
46
|
+
.runInContext(context, { timeout: 1000 });
|
|
47
|
+
results.push({ ok: true, result });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
results.push({ ok: false, error: preview(error) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
process.stdout.write(JSON.stringify({ ok: true, results }));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
process.stdout.write(JSON.stringify({ ok: false, error: preview(error) }));
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function parseBatchResults(value, expectedLength) {
|
|
62
|
+
if (!isRecord(value) || value.ok !== true || !Array.isArray(value.results) || value.results.length !== expectedLength)
|
|
63
|
+
return null;
|
|
64
|
+
const results = [];
|
|
65
|
+
for (const candidate of value.results) {
|
|
66
|
+
if (!isRecord(candidate) || typeof candidate.ok !== 'boolean')
|
|
67
|
+
return null;
|
|
68
|
+
if (candidate.ok) {
|
|
69
|
+
if (!Object.hasOwn(candidate, 'result'))
|
|
70
|
+
return null;
|
|
71
|
+
results.push({ ok: true, result: candidate.result });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (typeof candidate.error !== 'string')
|
|
75
|
+
return null;
|
|
76
|
+
results.push({ ok: false, error: candidate.error.slice(0, 500) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
export function createNodeRuleImplementationRuntime() {
|
|
82
|
+
return {
|
|
83
|
+
evaluateBatch(rules, input, timeoutMs) {
|
|
84
|
+
const child = spawnSync(process.execPath, ['--max-old-space-size=32', '-e', EVALUATION_PROCESS_SOURCE], {
|
|
85
|
+
input: JSON.stringify({ rules, input }), encoding: 'utf8', timeout: Math.max(1, Math.min(timeoutMs, RULE_BATCH_TIMEOUT_MS)),
|
|
86
|
+
maxBuffer: RULE_BATCH_OUTPUT_BYTES, windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
if (child.error) {
|
|
89
|
+
const code = isRecord(child.error) && typeof child.error.code === 'string' ? child.error.code : '';
|
|
90
|
+
if (code === 'ETIMEDOUT')
|
|
91
|
+
return { ok: false, reason: 'rule_batch_timeout', detail: child.error.message.slice(0, 500) };
|
|
92
|
+
if (code === 'ENOBUFS')
|
|
93
|
+
return { ok: false, reason: 'rule_batch_output_exceeded', detail: child.error.message.slice(0, 500) };
|
|
94
|
+
return { ok: false, reason: 'rule_batch_failed', detail: child.error.message.slice(0, 500) };
|
|
95
|
+
}
|
|
96
|
+
if (Buffer.byteLength(child.stdout, 'utf8') >= RULE_BATCH_OUTPUT_BYTES) {
|
|
97
|
+
return { ok: false, reason: 'rule_batch_output_exceeded', detail: 'child output reached the configured byte bound' };
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(child.stdout);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return { ok: false, reason: 'rule_batch_failed', detail: `child returned invalid JSON: ${child.stderr.trim().slice(0, 300)}` };
|
|
105
|
+
}
|
|
106
|
+
const results = parseBatchResults(parsed, rules.length);
|
|
107
|
+
if (!results) {
|
|
108
|
+
const detail = isRecord(parsed) && typeof parsed.error === 'string' ? parsed.error.slice(0, 500) : 'child result shape invalid';
|
|
109
|
+
return { ok: false, reason: 'rule_batch_failed', detail };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, results };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@principles/host-runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"lint": "eslint \"src/**/*.ts\""
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@principles/core": "^1.74.1",
|
|
25
|
+
"better-sqlite3": "^13.0.3",
|
|
26
|
+
"js-yaml": "^5.2.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
30
|
+
"@types/node": "^26.1.2",
|
|
31
|
+
"@types/js-yaml": "^4.0.9",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vite": "^8.0.16",
|
|
34
|
+
"vitest": "^4.1.10"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|