principles-disciple 1.62.0 → 1.64.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/evolution-status.ts +32 -21
- package/src/core/paths.ts +1 -0
- package/src/core/workflow-funnel-loader.ts +36 -5
- package/src/hooks/gate-block-helper.ts +1 -1
- package/src/hooks/gate.ts +27 -205
- package/src/service/runtime-summary-service.ts +5 -1
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +14 -14
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +14 -15
- package/tests/core/workflow-funnel-loader.test.ts +866 -0
- package/tests/hooks/gate-rule-host-pipeline.test.ts +159 -334
- package/tests/service/cooldown-strategy.test.ts +1 -0
- package/tests/service/evolution-worker.compilation-backfill.test.ts +5 -1
- package/src/hooks/bash-risk.ts +0 -175
- package/src/hooks/edit-verification.ts +0 -302
- package/src/hooks/gfi-gate.ts +0 -186
- package/src/hooks/progressive-trust-gate.ts +0 -183
- package/src/hooks/thinking-checkpoint.ts +0 -76
- package/tests/hooks/bash-risk-integration.test.ts +0 -137
- package/tests/hooks/bash-risk.test.ts +0 -81
- package/tests/hooks/edit-verification.test.ts +0 -678
- package/tests/hooks/gate-edit-verification-p1.test.ts +0 -632
- package/tests/hooks/gate-pipeline-integration.test.ts +0 -404
- package/tests/hooks/gate.test.ts +0 -271
- package/tests/hooks/gfi-gate-unit.test.ts +0 -422
- package/tests/hooks/gfi-gate.test.ts +0 -669
- package/tests/hooks/thinking-gate.test.ts +0 -313
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
1
2
|
import type { EvolutionReducerImpl } from '../core/evolution-reducer.js';
|
|
2
3
|
import type { InternalizationRouteRecommendation } from '../core/principle-internalization/internalization-routing-policy.js';
|
|
4
|
+
import { WorkflowFunnelLoader } from '../core/workflow-funnel-loader.js';
|
|
5
|
+
import { resolvePdPath } from '../core/paths.js';
|
|
3
6
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
4
7
|
import { normalizeLanguage } from '../i18n/commands.js';
|
|
5
8
|
import type { PluginCommandContext } from '../openclaw-sdk.js';
|
|
@@ -175,18 +178,35 @@ export function handleEvolutionStatusCommand(ctx: PluginCommandContext): { text:
|
|
|
175
178
|
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
176
179
|
const reducer = wctx.evolutionReducer;
|
|
177
180
|
const stats = reducer.getStats();
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
// D-12 / YAML-FUNNEL-02: WorkflowFunnelLoader owns funnel lifecycle per workspace
|
|
182
|
+
const stateDir = path.dirname(resolvePdPath(workspaceDir, 'WORKFLOWS_YAML'));
|
|
183
|
+
const loader = new WorkflowFunnelLoader(stateDir);
|
|
184
|
+
loader.watch();
|
|
185
|
+
try {
|
|
186
|
+
const summary = RuntimeSummaryService.getSummary(workspaceDir, { sessionId, loaderWarnings: loader.getWarnings() });
|
|
187
|
+
const recommendations = WorkspaceContext.fromHookContext({ workspaceDir })
|
|
188
|
+
.principleLifecycle
|
|
189
|
+
.recomputeAll()
|
|
190
|
+
.map((assessment) => assessment.routeRecommendation);
|
|
191
|
+
const rawLang = (ctx.config?.language as string) || 'en';
|
|
192
|
+
const lang = normalizeLanguage(rawLang);
|
|
193
|
+
const warnings = summary.metadata.warnings.slice(0, 12);
|
|
194
|
+
|
|
195
|
+
if (lang === 'zh') {
|
|
196
|
+
return {
|
|
197
|
+
text: buildChineseOutput(
|
|
198
|
+
workspaceDir,
|
|
199
|
+
summary.metadata.sessionId,
|
|
200
|
+
warnings,
|
|
201
|
+
stats,
|
|
202
|
+
summary,
|
|
203
|
+
recommendations,
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
186
207
|
|
|
187
|
-
if (lang === 'zh') {
|
|
188
208
|
return {
|
|
189
|
-
text:
|
|
209
|
+
text: buildEnglishOutput(
|
|
190
210
|
workspaceDir,
|
|
191
211
|
summary.metadata.sessionId,
|
|
192
212
|
warnings,
|
|
@@ -195,16 +215,7 @@ export function handleEvolutionStatusCommand(ctx: PluginCommandContext): { text:
|
|
|
195
215
|
recommendations,
|
|
196
216
|
),
|
|
197
217
|
};
|
|
218
|
+
} finally {
|
|
219
|
+
loader.dispose(); // YAML-FUNNEL-02: guarantee cleanup on all exit paths
|
|
198
220
|
}
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
text: buildEnglishOutput(
|
|
202
|
-
workspaceDir,
|
|
203
|
-
summary.metadata.sessionId,
|
|
204
|
-
warnings,
|
|
205
|
-
stats,
|
|
206
|
-
summary,
|
|
207
|
-
recommendations,
|
|
208
|
-
),
|
|
209
|
-
};
|
|
210
221
|
}
|
package/src/core/paths.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const PD_FILES = {
|
|
|
62
62
|
SESSION_DIR: PD_DIRS.SESSIONS,
|
|
63
63
|
DICTIONARY: posixJoin(PD_DIRS.STATE, 'pain_dictionary.json'),
|
|
64
64
|
PRINCIPLE_BLACKLIST: posixJoin(PD_DIRS.STATE, 'principle_blacklist.json'),
|
|
65
|
+
WORKFLOWS_YAML: posixJoin(PD_DIRS.STATE, 'workflows.yaml'),
|
|
65
66
|
NOCTURNAL_SAMPLES_DIR: PD_DIRS.NOCTURNAL_SAMPLES,
|
|
66
67
|
NOCTURNAL_MEMORY_DIR: PD_DIRS.NOCTURNAL_MEMORY,
|
|
67
68
|
NOCTURNAL_EXPORTS_DIR: PD_DIRS.NOCTURNAL_EXPORTS,
|
|
@@ -72,6 +72,9 @@ export class WorkflowFunnelLoader {
|
|
|
72
72
|
/** fs.watch() handle for cleanup */
|
|
73
73
|
private watchHandle?: fs.FSWatcher;
|
|
74
74
|
|
|
75
|
+
/** YAML parse warnings from last load() call */
|
|
76
|
+
private readonly warnings: string[] = [];
|
|
77
|
+
|
|
75
78
|
constructor(stateDir: string) {
|
|
76
79
|
// D-02: workflows.yaml in .state/ directory
|
|
77
80
|
this.configPath = path.join(stateDir, 'workflows.yaml');
|
|
@@ -84,7 +87,9 @@ export class WorkflowFunnelLoader {
|
|
|
84
87
|
* On missing file, clears to empty.
|
|
85
88
|
*/
|
|
86
89
|
load(): void {
|
|
90
|
+
this.warnings.length = 0; // reset warnings on each load
|
|
87
91
|
if (!fs.existsSync(this.configPath)) {
|
|
92
|
+
this.warnings.push('workflows.yaml file not found.');
|
|
88
93
|
this.funnels.clear();
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
@@ -96,7 +101,9 @@ export class WorkflowFunnelLoader {
|
|
|
96
101
|
|
|
97
102
|
// Validate top-level structure
|
|
98
103
|
if (!config || typeof config.version !== 'string' || !Array.isArray(config.funnels)) {
|
|
99
|
-
|
|
104
|
+
const msg = 'workflows.yaml validation failed: missing version or funnels array. Preserving last valid config.';
|
|
105
|
+
console.warn(`[WorkflowFunnelLoader] ${msg}`);
|
|
106
|
+
this.warnings.push(msg);
|
|
100
107
|
return;
|
|
101
108
|
}
|
|
102
109
|
|
|
@@ -106,7 +113,9 @@ export class WorkflowFunnelLoader {
|
|
|
106
113
|
if (funnel?.workflowId && typeof funnel.workflowId === 'string' && Array.isArray(funnel.stages)) {
|
|
107
114
|
newFunnels.set(funnel.workflowId, funnel.stages);
|
|
108
115
|
} else {
|
|
109
|
-
|
|
116
|
+
const msg = 'Skipping invalid funnel entry: missing workflowId or stages.';
|
|
117
|
+
console.warn(`[WorkflowFunnelLoader] ${msg}`);
|
|
118
|
+
this.warnings.push(msg);
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -117,19 +126,27 @@ export class WorkflowFunnelLoader {
|
|
|
117
126
|
}
|
|
118
127
|
} catch (err) {
|
|
119
128
|
// Best-effort: preserve last known-good config on parse error
|
|
120
|
-
|
|
129
|
+
const msg = `Failed to parse workflows.yaml: ${String(err)}. Preserving last valid config.`;
|
|
130
|
+
console.warn(`[WorkflowFunnelLoader] ${msg}`);
|
|
131
|
+
this.warnings.push(msg);
|
|
121
132
|
}
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
/**
|
|
125
136
|
* Start watching workflows.yaml for changes.
|
|
126
137
|
* Calls load() automatically when the file changes.
|
|
138
|
+
* No-op if the config file does not exist.
|
|
127
139
|
*/
|
|
128
140
|
watch(): void {
|
|
141
|
+
// WATCHER-01: re-entry guard — prevent FSWatcher leak on double-watch
|
|
142
|
+
if (this.watchHandle) return;
|
|
143
|
+
// Guard: fs.watch fails with ENOENT if the path does not exist
|
|
144
|
+
if (!fs.existsSync(this.configPath)) return;
|
|
129
145
|
// Debounce: only re-read after file write settles (100ms)
|
|
130
146
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
131
147
|
this.watchHandle = fs.watch(this.configPath, (eventType) => {
|
|
132
|
-
|
|
148
|
+
// PLAT-01: handle both 'change' and 'rename' events for Windows compatibility
|
|
149
|
+
if (eventType !== 'change' && eventType !== 'rename') return;
|
|
133
150
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
134
151
|
debounceTimer = setTimeout(() => {
|
|
135
152
|
this.load();
|
|
@@ -156,9 +173,23 @@ export class WorkflowFunnelLoader {
|
|
|
156
173
|
|
|
157
174
|
/**
|
|
158
175
|
* Get the full WORKFLOW_FUNNELS table.
|
|
176
|
+
* Returns a deep clone — consumer mutations do not affect internal state.
|
|
159
177
|
*/
|
|
160
178
|
getAllFunnels(): Map<string, WorkflowStage[]> {
|
|
161
|
-
|
|
179
|
+
const result = new Map<string, WorkflowStage[]>();
|
|
180
|
+
for (const [k, v] of this.funnels) {
|
|
181
|
+
// WATCHER-03: deep-clone arrays and stage objects
|
|
182
|
+
result.set(k, v.map(stage => ({ ...stage })));
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Returns warnings from the last load() call.
|
|
189
|
+
* Callers can inspect these and propagate them to metadata.warnings.
|
|
190
|
+
*/
|
|
191
|
+
getWarnings(): string[] {
|
|
192
|
+
return [...this.warnings];
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* PURPOSE: Provide ONE authoritative implementation for gate block persistence.
|
|
5
5
|
*
|
|
6
|
-
* All gate
|
|
6
|
+
* All gate sources (rule-host) must use this
|
|
7
7
|
* helper to ensure consistent block tracking, event logging, and retry behavior.
|
|
8
8
|
*
|
|
9
9
|
* This eliminates the "multi-truth source" problem where different modules
|
package/src/hooks/gate.ts
CHANGED
|
@@ -1,45 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Security Gate Hook -
|
|
2
|
+
* Security Gate Hook - Rule Host Only
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* This is the SINGLE AUTHORITATIVE orchestration path.
|
|
5
|
+
* All blocking logic is now dynamic via Rule Host — no hardcoded gates remain.
|
|
5
6
|
*
|
|
7
|
+
* Flow:
|
|
6
8
|
* 1. Early Return: Skip if not write/bash/agent tool or no workspace
|
|
7
|
-
* 2.
|
|
8
|
-
* 3. GFI Gate: Fatigue index-based blocking
|
|
9
|
-
* 4. Bash Mutation Detection: Heuristic for bash file modifications
|
|
10
|
-
* 4.5. Rule Host: Active code implementation evaluation (Phase 12)
|
|
11
|
-
* 5. Progressive Gate: EP tier-based access control
|
|
12
|
-
* 6. Edit Verification (P-03): Exact/fuzzy match for edit operations
|
|
13
|
-
*
|
|
14
|
-
* IMPORTANT: This is the SINGLE AUTHORITATIVE orchestration path.
|
|
15
|
-
* All policy modules (gfi-gate, progressive-trust-gate, rule-host) use the shared
|
|
16
|
-
* `recordGateBlockAndReturn` helper to ensure consistent block persistence.
|
|
17
|
-
*
|
|
18
|
-
* Zero-width character detection is handled in bash-risk.ts.
|
|
9
|
+
* 2. Rule Host: Dynamic principle-based evaluation (sole gate)
|
|
19
10
|
*/
|
|
20
11
|
|
|
21
12
|
import * as fs from 'fs';
|
|
22
13
|
import * as path from 'path';
|
|
23
|
-
import {
|
|
24
|
-
import { normalizeProfile } from '../core/profile.js';
|
|
25
|
-
import { estimateLineChanges } from '../core/risk-calculator.js';
|
|
14
|
+
import { normalizePath, planStatus } from '../utils/io.js';
|
|
26
15
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
27
|
-
import { checkThinkingCheckpoint } from './thinking-checkpoint.js';
|
|
28
|
-
import { handleEditVerification } from './edit-verification.js';
|
|
29
|
-
import { checkGfiGate } from './gfi-gate.js';
|
|
30
|
-
import { checkProgressiveTrustGate } from './progressive-trust-gate.js';
|
|
31
16
|
import { recordGateBlockAndReturn } from './gate-block-helper.js';
|
|
32
17
|
import { RuleHost } from '../core/rule-host.js';
|
|
33
18
|
import type { RuleHostInput } from '../core/rule-host-types.js';
|
|
34
19
|
import type { PluginHookBeforeToolCallEvent, PluginHookToolContext, PluginHookBeforeToolCallResult, PluginLogger } from '../openclaw-sdk.js';
|
|
35
|
-
import {
|
|
36
|
-
AGENT_TOOLS,
|
|
37
|
-
BASH_TOOLS_SET,
|
|
38
|
-
WRITE_TOOLS,
|
|
39
|
-
} from '../constants/tools.js';
|
|
20
|
+
import { AGENT_TOOLS, BASH_TOOLS_SET, WRITE_TOOLS } from '../constants/tools.js';
|
|
40
21
|
import { getSession, hasRecentThinking } from '../core/session-tracker.js';
|
|
41
22
|
import { getEvolutionEngine } from '../core/evolution-engine.js';
|
|
42
23
|
import { EventLogService } from '../core/event-log.js';
|
|
24
|
+
import { estimateLineChanges } from '../core/risk-calculator.js';
|
|
43
25
|
|
|
44
26
|
export function handleBeforeToolCall(
|
|
45
27
|
event: PluginHookBeforeToolCallEvent,
|
|
@@ -58,154 +40,57 @@ export function handleBeforeToolCall(
|
|
|
58
40
|
|
|
59
41
|
const wctx = WorkspaceContext.fromHookContext(ctx);
|
|
60
42
|
|
|
61
|
-
// 2.
|
|
62
|
-
const profilePath = wctx.resolve('PROFILE');
|
|
63
|
-
let profile = {
|
|
64
|
-
risk_paths: [] as string[],
|
|
65
|
-
gate: { require_plan_for_risk_paths: true },
|
|
66
|
-
progressive_gate: {
|
|
67
|
-
enabled: true,
|
|
68
|
-
plan_approvals: {
|
|
69
|
-
enabled: false,
|
|
70
|
-
max_lines_override: -1,
|
|
71
|
-
allowed_patterns: [] as string[],
|
|
72
|
-
allowed_operations: [] as string[],
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
edit_verification: {
|
|
76
|
-
enabled: true,
|
|
77
|
-
max_file_size_bytes: 10 * 1024 * 1024,
|
|
78
|
-
fuzzy_match_enabled: true,
|
|
79
|
-
fuzzy_match_threshold: 0.8,
|
|
80
|
-
skip_large_file_action: 'warn' as 'warn' | 'block',
|
|
81
|
-
},
|
|
82
|
-
thinking_checkpoint: {
|
|
83
|
-
enabled: false, // Default OFF
|
|
84
|
-
window_ms: 5 * 60 * 1000,
|
|
85
|
-
high_risk_tools: ['run_shell_command', 'delete_file', 'move_file'],
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
if (fs.existsSync(profilePath)) {
|
|
90
|
-
try {
|
|
91
|
-
const rawProfile = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
|
92
|
-
profile = normalizeProfile(rawProfile);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
logger?.error?.(`[PD_GATE] Failed to parse PROFILE.json: ${String(e)}`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
99
|
-
// POLICY STEP 1: Thinking OS Checkpoint (P-10)
|
|
100
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
-
// Only enforced when thinking_checkpoint.enabled = true in PROFILE.json
|
|
102
|
-
const thinkingResult = checkThinkingCheckpoint(
|
|
103
|
-
event,
|
|
104
|
-
profile.thinking_checkpoint || {},
|
|
105
|
-
ctx.sessionId,
|
|
106
|
-
logger
|
|
107
|
-
);
|
|
108
|
-
if (thinkingResult) {
|
|
109
|
-
return thinkingResult;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
-
// POLICY STEP 2: GFI Gate - Hard Intercept
|
|
114
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
-
// 根据 GFI (疲劳指数) 精细化拦截工具调用
|
|
116
|
-
// 注意:TIER 0 (只读工具) 已在早期过滤中放行,此处不检查
|
|
117
|
-
const gfiGateConfig = wctx.config.get('gfi_gate');
|
|
118
|
-
const gfiResult = checkGfiGate(event, wctx, ctx.sessionId, gfiGateConfig, logger);
|
|
119
|
-
if (gfiResult) {
|
|
120
|
-
return gfiResult;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Merge pluginConfig (OpenClaw UI settings)
|
|
124
|
-
const configRiskPaths = (ctx.pluginConfig?.riskPaths as string[] | undefined) ?? [];
|
|
125
|
-
if (configRiskPaths.length > 0) {
|
|
126
|
-
profile.risk_paths = [...new Set([...profile.risk_paths, ...configRiskPaths])];
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// 3. Resolve the target file path
|
|
43
|
+
// 2. Resolve the target file path
|
|
130
44
|
let filePath = event.params.file_path || event.params.path || event.params.file || event.params.target;
|
|
131
45
|
|
|
132
46
|
// Heuristic for bash mutation detection
|
|
133
47
|
if (isBash && !filePath) {
|
|
134
|
-
const command = String(event.params.command || event.params.args ||
|
|
48
|
+
const command = String(event.params.command || event.params.args || '');
|
|
135
49
|
const mutationMatch = /(?:>|>>|sed\s+-i|rm|mv|mkdir|touch|cp)\s+(?:-[a-zA-Z]+\s+)*([^\s;&|<>]+)/.exec(command);
|
|
136
50
|
|
|
137
51
|
if (mutationMatch) {
|
|
138
|
-
|
|
139
|
-
|
|
140
52
|
filePath = mutationMatch[1];
|
|
141
53
|
} else {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (hasRiskPath && isMutation) {
|
|
146
|
-
filePath = command;
|
|
147
|
-
} else {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
54
|
+
// Bash command without a clear file target — let it through to Rule Host
|
|
55
|
+
filePath = command;
|
|
150
56
|
}
|
|
151
57
|
}
|
|
152
58
|
|
|
153
59
|
if (typeof filePath !== 'string') return;
|
|
154
60
|
|
|
155
61
|
const relPath = normalizePath(filePath, ctx.workspaceDir);
|
|
156
|
-
const risky = (isBash && filePath.includes(' '))
|
|
157
|
-
? profile.risk_paths.some(rp => filePath.includes(rp))
|
|
158
|
-
: isRisky(relPath, profile.risk_paths);
|
|
159
62
|
|
|
160
|
-
//
|
|
161
|
-
// POLICY STEP 2.5: Rule Host Evaluation (Phase 12, D-01/D-03)
|
|
162
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
-
// Inserted between GFI gate and Progressive Gate so principle rules can act
|
|
164
|
-
// before the capability-boundary fallback. Active code implementations run
|
|
165
|
-
// through a constrained vm context with minimal helpers only.
|
|
63
|
+
// 3. Rule Host Evaluation — sole gate
|
|
166
64
|
try {
|
|
167
65
|
const ruleHost = new RuleHost(wctx.stateDir, logger);
|
|
168
66
|
const hostInput: RuleHostInput = {
|
|
169
67
|
action: {
|
|
170
68
|
toolName: event.toolName,
|
|
171
69
|
normalizedPath: relPath,
|
|
172
|
-
|
|
173
|
-
|
|
174
70
|
paramsSummary: _extractParamsSummary(event.params),
|
|
175
71
|
},
|
|
176
72
|
workspace: {
|
|
177
|
-
isRiskPath:
|
|
178
|
-
|
|
179
|
-
|
|
73
|
+
isRiskPath: false, // Rule Host determines risk dynamically
|
|
180
74
|
planStatus: _getPlanStatus(ctx.workspaceDir),
|
|
181
|
-
|
|
182
|
-
|
|
183
75
|
hasPlanFile: _hasPlanFile(ctx.workspaceDir),
|
|
184
76
|
},
|
|
185
77
|
session: {
|
|
186
78
|
sessionId: ctx.sessionId,
|
|
187
|
-
|
|
188
|
-
|
|
189
79
|
currentGfi: _getCurrentGfi(ctx.sessionId),
|
|
190
|
-
|
|
191
|
-
|
|
192
80
|
recentThinking: _hasRecentThinking(ctx.sessionId),
|
|
193
81
|
},
|
|
194
82
|
evolution: {
|
|
195
|
-
|
|
196
|
-
|
|
197
83
|
epTier: _getEpTier(wctx.workspaceDir),
|
|
198
84
|
},
|
|
199
85
|
derived: {
|
|
200
86
|
estimatedLineChanges: estimateLineChanges({ toolName: event.toolName, params: event.params }),
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
bashRisk: _getBashRisk(event, profile),
|
|
87
|
+
bashRisk: _getBashRisk(event),
|
|
204
88
|
},
|
|
205
89
|
};
|
|
206
90
|
|
|
207
91
|
const hostResult = ruleHost.evaluate(hostInput);
|
|
208
|
-
|
|
92
|
+
|
|
93
|
+
// Always emit rulehost_evaluated
|
|
209
94
|
try {
|
|
210
95
|
const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
|
|
211
96
|
eventLog.recordRuleHostEvaluated({
|
|
@@ -218,8 +103,8 @@ export function handleBeforeToolCall(
|
|
|
218
103
|
} catch (evErr) {
|
|
219
104
|
logger?.warn?.(`[PD_GATE] Failed to record rulehost_evaluated: ${String(evErr)}`);
|
|
220
105
|
}
|
|
106
|
+
|
|
221
107
|
if (hostResult?.decision === 'block') {
|
|
222
|
-
// C: Record rule_enforced event for matched rules
|
|
223
108
|
try {
|
|
224
109
|
const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
|
|
225
110
|
eventLog.recordRuleEnforced({
|
|
@@ -236,18 +121,18 @@ export function handleBeforeToolCall(
|
|
|
236
121
|
ruleId: hostResult.ruleId,
|
|
237
122
|
});
|
|
238
123
|
} catch (evErr) {
|
|
239
|
-
logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_blocked
|
|
124
|
+
logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_blocked: ${String(evErr)}`);
|
|
240
125
|
}
|
|
241
126
|
|
|
242
|
-
const reason = hostResult.reason;
|
|
243
127
|
return recordGateBlockAndReturn(wctx, {
|
|
244
128
|
filePath: relPath,
|
|
245
|
-
reason,
|
|
129
|
+
reason: hostResult.reason,
|
|
246
130
|
toolName: event.toolName,
|
|
247
131
|
sessionId: ctx.sessionId,
|
|
248
132
|
blockSource: 'rule-host',
|
|
249
133
|
}, logger);
|
|
250
134
|
}
|
|
135
|
+
|
|
251
136
|
if (hostResult?.decision === 'requireApproval') {
|
|
252
137
|
try {
|
|
253
138
|
const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
|
|
@@ -265,68 +150,12 @@ export function handleBeforeToolCall(
|
|
|
265
150
|
ruleId: hostResult.ruleId,
|
|
266
151
|
});
|
|
267
152
|
} catch (evErr) {
|
|
268
|
-
logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_requireApproval
|
|
153
|
+
logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_requireApproval: ${String(evErr)}`);
|
|
269
154
|
}
|
|
270
155
|
}
|
|
271
156
|
} catch (hostError: unknown) {
|
|
272
|
-
// D-08: Conservative degradation — log and
|
|
273
|
-
logger.warn?.(`[PD_GATE:RULE_HOST] Host evaluation failed,
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
-
// POLICY STEP 3: Progressive Trust Gate (Stage 1-4 access control)
|
|
278
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
279
|
-
// IMPORTANT: This step does NOT return early on allow.
|
|
280
|
-
// We must continue to edit verification for ALL allowed operations.
|
|
281
|
-
if (profile.progressive_gate?.enabled) {
|
|
282
|
-
const lineChanges = estimateLineChanges({ toolName: event.toolName, params: event.params });
|
|
283
|
-
const progressiveGateResult = checkProgressiveTrustGate(
|
|
284
|
-
event,
|
|
285
|
-
wctx,
|
|
286
|
-
relPath,
|
|
287
|
-
risky,
|
|
288
|
-
lineChanges,
|
|
289
|
-
logger,
|
|
290
|
-
ctx,
|
|
291
|
-
profile
|
|
292
|
-
);
|
|
293
|
-
if (progressiveGateResult) {
|
|
294
|
-
return progressiveGateResult;
|
|
295
|
-
}
|
|
296
|
-
// NOTE: Do NOT return here! Continue to edit verification.
|
|
297
|
-
// All allowed operations (regardless of EP tier) should still run edit verification.
|
|
298
|
-
} else {
|
|
299
|
-
// FALLBACK: Legacy Gate Logic (when progressive gate is disabled)
|
|
300
|
-
if (risky && profile.gate?.require_plan_for_risk_paths) {
|
|
301
|
-
const planStatus = getPlanStatus(ctx.workspaceDir);
|
|
302
|
-
if (planStatus !== 'READY') {
|
|
303
|
-
return recordGateBlockAndReturn(wctx, {
|
|
304
|
-
filePath: relPath,
|
|
305
|
-
reason: `No READY plan found in PLAN.md.`,
|
|
306
|
-
toolName: event.toolName,
|
|
307
|
-
sessionId: ctx.sessionId,
|
|
308
|
-
blockSource: 'gate-legacy',
|
|
309
|
-
}, logger);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
-
// POLICY STEP 4: Edit Tool Verification (P-03)
|
|
316
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
317
|
-
// This MUST run after all other gate checks for ALL tools.
|
|
318
|
-
// Edit verification ensures oldText matches the actual file content.
|
|
319
|
-
if (event.toolName === 'edit' && profile.edit_verification?.enabled !== false) {
|
|
320
|
-
const verifyResult = handleEditVerification(event, wctx, ctx, {
|
|
321
|
-
enabled: profile.edit_verification.enabled,
|
|
322
|
-
max_file_size_bytes: profile.edit_verification.max_file_size_bytes,
|
|
323
|
-
fuzzy_match_enabled: profile.edit_verification.fuzzy_match_enabled,
|
|
324
|
-
fuzzy_match_threshold: profile.edit_verification.fuzzy_match_threshold,
|
|
325
|
-
skip_large_file_action: profile.edit_verification.skip_large_file_action as 'warn' | 'block' | undefined,
|
|
326
|
-
});
|
|
327
|
-
if (verifyResult) {
|
|
328
|
-
return verifyResult; // Block or modify params
|
|
329
|
-
}
|
|
157
|
+
// D-08: Conservative degradation — log and allow on Rule Host failure
|
|
158
|
+
logger.warn?.(`[PD_GATE:RULE_HOST] Host evaluation failed, allowing conservatively: ${String(hostError)}`);
|
|
330
159
|
}
|
|
331
160
|
|
|
332
161
|
// All checks passed - allow the operation
|
|
@@ -334,9 +163,7 @@ export function handleBeforeToolCall(
|
|
|
334
163
|
}
|
|
335
164
|
|
|
336
165
|
// ---------------------------------------------------------------------------
|
|
337
|
-
// Private helpers
|
|
338
|
-
// These are NOT passed to hosted implementations — they only populate the
|
|
339
|
-
// frozen snapshot that implementations receive.
|
|
166
|
+
// Private helpers
|
|
340
167
|
// ---------------------------------------------------------------------------
|
|
341
168
|
|
|
342
169
|
function _extractParamsSummary(params: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -352,7 +179,7 @@ function _extractParamsSummary(params: Record<string, unknown>): Record<string,
|
|
|
352
179
|
|
|
353
180
|
function _getPlanStatus(workspaceDir: string): 'NONE' | 'DRAFT' | 'READY' | 'UNKNOWN' {
|
|
354
181
|
try {
|
|
355
|
-
const status =
|
|
182
|
+
const status = planStatus(workspaceDir);
|
|
356
183
|
if (status === 'READY') return 'READY';
|
|
357
184
|
if (status === 'DRAFT') return 'DRAFT';
|
|
358
185
|
if (status === '') return 'NONE';
|
|
@@ -397,12 +224,7 @@ function _getEpTier(workspaceDir: string): number {
|
|
|
397
224
|
}
|
|
398
225
|
}
|
|
399
226
|
|
|
400
|
-
|
|
401
|
-
function _getBashRisk(
|
|
402
|
-
event: PluginHookBeforeToolCallEvent,
|
|
403
|
-
_profile: { risk_paths: string[] }
|
|
404
|
-
): 'safe' | 'normal' | 'dangerous' | 'unknown' {
|
|
405
|
-
|
|
227
|
+
function _getBashRisk(event: PluginHookBeforeToolCallEvent): 'safe' | 'normal' | 'dangerous' | 'unknown' {
|
|
406
228
|
if (!BASH_TOOLS_SET.has(event.toolName)) return 'unknown';
|
|
407
229
|
try {
|
|
408
230
|
const command = String(event.params.command || event.params.args || '');
|
|
@@ -170,10 +170,14 @@ function pushWarning(warnings: string[], message: string): void {
|
|
|
170
170
|
export class RuntimeSummaryService {
|
|
171
171
|
static getSummary(
|
|
172
172
|
workspaceDir: string,
|
|
173
|
-
options?: { sessionId?: string | null }
|
|
173
|
+
options?: { sessionId?: string | null; loaderWarnings?: string[] }
|
|
174
174
|
): RuntimeSummary {
|
|
175
175
|
const generatedAt = new Date().toISOString();
|
|
176
176
|
const warnings: string[] = [];
|
|
177
|
+
// ERR-01: surface loader warnings (YAML parse failures) into metadata.warnings
|
|
178
|
+
if (options?.loaderWarnings) {
|
|
179
|
+
warnings.push(...options.loaderWarnings);
|
|
180
|
+
}
|
|
177
181
|
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
178
182
|
|
|
179
183
|
const sessions = this.mergeSessionSnapshots(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pd-pain-signal
|
|
3
|
-
description: Manually inject a pain signal into the evolution system.
|
|
4
|
-
disable-model-invocation:
|
|
3
|
+
description: Manually inject a pain signal into the evolution system. Trigger conditions: (1) User reports agent stuck/looping/unresponsive (2) User says "record this issue", "force reflection", "trigger pain", "create pain signal", "人工注入 pain", "创建一个痛苦信号" (3) Tool failure with no follow-up action (4) User provides human intervention feedback. Mandatory enforcement: any manual pain flag creation scenario MUST use this skill, no bypassing allowed.
|
|
4
|
+
disable-model-invocation: false
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Pain Signal (Force Pain Signal)
|
|
@@ -12,28 +12,21 @@ You are now the "Manual Intervention Pain" component.
|
|
|
12
12
|
1. Record the user's feedback `$ARGUMENTS` as a **high-priority** pain signal.
|
|
13
13
|
2. Inform the user that the signal has been injected, and suggest waiting for the next Hook trigger (e.g., Stop or PreCompact) or manually running `/reflection-log`.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**Mandatory Enforcement**:
|
|
16
16
|
|
|
17
|
-
**
|
|
18
|
-
|
|
19
|
-
```
|
|
20
|
-
write_pain_flag({
|
|
21
|
-
reason: "User feedback or error description",
|
|
22
|
-
score: 80,
|
|
23
|
-
source: "human_intervention",
|
|
24
|
-
is_risky: false
|
|
25
|
-
})
|
|
26
|
-
```
|
|
17
|
+
When you need to create, write, or manually create a pain flag, you **MUST** use this skill via the `write_pain_flag` tool. Any bypassing of this skill to directly operate on files violates the mandatory constraint of this skill.
|
|
27
18
|
|
|
28
19
|
**Absolutely forbidden**:
|
|
29
20
|
- ❌ Writing to `.state/.pain_flag` directly (any method)
|
|
30
21
|
- ❌ Using bash heredoc (`cat <<EOF > .pain_flag`)
|
|
31
22
|
- ❌ Using `echo "..." > .pain_flag`
|
|
23
|
+
- ❌ Using `Set-Content` / `Out-File` or other PowerShell file-writing cmdlets
|
|
32
24
|
- ❌ Using `node -e` to call `writePainFlag` or `buildPainFlag`
|
|
33
25
|
- ❌ Any method that `toString()` a JavaScript object to the file
|
|
26
|
+
- ❌ Using `exec` tool to invoke shell commands to write the pain_flag file
|
|
34
27
|
|
|
35
28
|
**Why use the tool?**
|
|
36
|
-
The `write_pain_flag` tool encapsulates correct KV-format serialization, ensuring `.pain_flag` is never corrupted. Historically, direct file writes caused `[object Object]` corruption
|
|
29
|
+
The `write_pain_flag` tool encapsulates correct KV-format serialization, ensuring `.pain_flag` is never corrupted. Historically, direct file writes caused `[object Object]` corruption and field loss (painScore → score mapping failure). Using the tool is the only safe path.
|
|
37
30
|
|
|
38
31
|
**Parameters**:
|
|
39
32
|
- `reason` (required): The reason for the pain signal — describe what went wrong
|
|
@@ -50,3 +43,10 @@ write_pain_flag({
|
|
|
50
43
|
is_risky: false
|
|
51
44
|
})
|
|
52
45
|
```
|
|
46
|
+
|
|
47
|
+
**Workflow**:
|
|
48
|
+
1. Recognize trigger condition → read this skill
|
|
49
|
+
2. Call `write_pain_flag` tool with `reason` and other parameters
|
|
50
|
+
3. Confirm tool executed successfully (returns ✅)
|
|
51
|
+
4. Inform user the pain signal has been injected; evolution system will process it on next heartbeat
|
|
52
|
+
5. Do NOT perform any direct file write operations after this
|