create-principles-disciple 1.104.2 → 1.105.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.
Files changed (71) hide show
  1. package/core/dist/host/__tests__/host-adapter.test.js +12 -0
  2. package/core/dist/host/__tests__/host-adapter.test.js.map +1 -1
  3. package/core/dist/host/host-adapter.d.ts +4 -0
  4. package/core/dist/host/host-adapter.d.ts.map +1 -1
  5. package/core/dist/host/host-adapter.js +7 -0
  6. package/core/dist/host/host-adapter.js.map +1 -1
  7. package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js +28 -0
  8. package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js.map +1 -1
  9. package/core/dist/runtime-v2/__tests__/sqlite-connection-readonly.test.js +18 -0
  10. package/core/dist/runtime-v2/__tests__/sqlite-connection-readonly.test.js.map +1 -1
  11. package/core/dist/runtime-v2/evidence-sanitizer.d.ts.map +1 -1
  12. package/core/dist/runtime-v2/evidence-sanitizer.js +17 -1
  13. package/core/dist/runtime-v2/evidence-sanitizer.js.map +1 -1
  14. package/core/dist/runtime-v2/feature-flags/__tests__/feature-flag-contract.test.js +15 -0
  15. package/core/dist/runtime-v2/feature-flags/__tests__/feature-flag-contract.test.js.map +1 -1
  16. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js +1 -1
  17. package/core/dist/runtime-v2/feature-flags/feature-flag-contract.js.map +1 -1
  18. package/core/dist/runtime-v2/store/sqlite-connection.d.ts +3 -0
  19. package/core/dist/runtime-v2/store/sqlite-connection.d.ts.map +1 -1
  20. package/core/dist/runtime-v2/store/sqlite-connection.js +6 -3
  21. package/core/dist/runtime-v2/store/sqlite-connection.js.map +1 -1
  22. package/dist/installer.d.ts.map +1 -1
  23. package/dist/installer.js +52 -2
  24. package/dist/installer.js.map +1 -1
  25. package/dist/installers/codex-host-installer.d.ts +14 -0
  26. package/dist/installers/codex-host-installer.d.ts.map +1 -1
  27. package/dist/installers/codex-host-installer.js +53 -8
  28. package/dist/installers/codex-host-installer.js.map +1 -1
  29. package/dist/mvp-config.d.ts +20 -0
  30. package/dist/mvp-config.d.ts.map +1 -1
  31. package/dist/mvp-config.js +76 -0
  32. package/dist/mvp-config.js.map +1 -1
  33. package/dist/utils/config-file-io.d.ts +26 -0
  34. package/dist/utils/config-file-io.d.ts.map +1 -0
  35. package/dist/utils/config-file-io.js +128 -0
  36. package/dist/utils/config-file-io.js.map +1 -0
  37. package/host-runtime/dist/active-principle-prompt.d.ts +17 -0
  38. package/host-runtime/dist/active-principle-prompt.js +102 -0
  39. package/host-runtime/dist/index.d.ts +45 -0
  40. package/host-runtime/dist/index.js +148 -0
  41. package/host-runtime/dist/pd-config.d.ts +36 -0
  42. package/host-runtime/dist/pd-config.js +98 -0
  43. package/host-runtime/dist/production-pain-evidence.d.ts +24 -0
  44. package/host-runtime/dist/production-pain-evidence.js +293 -0
  45. package/host-runtime/dist/production-rulehost-gate.d.ts +23 -0
  46. package/host-runtime/dist/production-rulehost-gate.js +294 -0
  47. package/host-runtime/dist/rule-implementation-runtime.d.ts +25 -0
  48. package/host-runtime/dist/rule-implementation-runtime.js +114 -0
  49. package/host-runtime/package.json +37 -0
  50. package/package.json +2 -1
  51. package/pd-cli/dist/commands/health-codex.d.ts +7 -0
  52. package/pd-cli/dist/commands/health-codex.d.ts.map +1 -0
  53. package/pd-cli/dist/commands/health-codex.js +209 -0
  54. package/pd-cli/dist/commands/health-codex.js.map +1 -0
  55. package/pd-cli/dist/commands/health.d.ts +24 -0
  56. package/pd-cli/dist/commands/health.d.ts.map +1 -1
  57. package/pd-cli/dist/commands/health.js +48 -0
  58. package/pd-cli/dist/commands/health.js.map +1 -1
  59. package/pd-cli/dist/index.js +2 -9
  60. package/pd-cli/dist/index.js.map +1 -1
  61. package/pd-cli/package.json +2 -0
  62. package/plugin/dist/bundle.js +615 -533
  63. package/plugin/dist/core/pd-config-loader.d.ts +7 -78
  64. package/plugin/dist/hooks/after-tool-call-helpers.d.ts +6 -2
  65. package/plugin/dist/hooks/gate.d.ts +29 -0
  66. package/plugin/dist/hooks/pain.d.ts +22 -0
  67. package/plugin/dist/hooks/prompt.d.ts +12 -2
  68. package/plugin/dist/host-runtime/openclaw-host-runtime.d.ts +19 -0
  69. package/plugin/dist/index.d.ts +8 -0
  70. package/plugin/openclaw.plugin.json +1 -1
  71. package/plugin/package.json +2 -1
@@ -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>;