principles-disciple 1.73.0 → 1.74.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 (33) hide show
  1. package/INSTALL.md +1 -3
  2. package/openclaw.plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/src/core/event-log.ts +0 -9
  5. package/src/core/migration.ts +0 -1
  6. package/src/core/path-resolver.ts +0 -1
  7. package/src/core/paths.ts +0 -1
  8. package/src/hooks/gate-block-helper.ts +25 -20
  9. package/src/hooks/gate.ts +13 -61
  10. package/src/hooks/prompt.ts +1 -61
  11. package/src/types/event-types.ts +0 -1
  12. package/src/utils/io.ts +0 -22
  13. package/templates/langs/en/core/AGENTS.md +5 -5
  14. package/templates/langs/en/principles/THINKING_OS.md +3 -2
  15. package/templates/langs/en/skills/ai-sprint-orchestration/runtime/.gitignore +2 -2
  16. package/templates/langs/en/skills/evolve-task/SKILL.md +2 -2
  17. package/templates/langs/en/skills/pd-mentor/SKILL.md +1 -2
  18. package/templates/langs/zh/core/AGENTS.md +5 -5
  19. package/templates/langs/zh/principles/THINKING_OS.md +3 -2
  20. package/templates/langs/zh/skills/ai-sprint-orchestration/runtime/.gitignore +2 -2
  21. package/templates/langs/zh/skills/evolve-task/SKILL.md +2 -2
  22. package/templates/langs/zh/skills/pd-mentor/SKILL.md +1 -2
  23. package/tests/core/migration.test.ts +7 -7
  24. package/tests/core/path-resolver.test.ts +1 -1
  25. package/tests/core/paths-refactor.test.ts +0 -22
  26. package/tests/core/workspace-context.test.ts +2 -2
  27. package/tests/core-anti-growth.test.ts +0 -1
  28. package/tests/hooks/confirm-first-removal.test.ts +188 -0
  29. package/tests/hooks/gate-no-path-write-tool.test.ts +172 -0
  30. package/src/core/confirm-first-gate.ts +0 -255
  31. package/templates/langs/en/skills/plan-script/SKILL.md +0 -32
  32. package/templates/langs/zh/skills/plan-script/SKILL.md +0 -32
  33. package/tests/hooks/confirm-first-gate.test.ts +0 -333
@@ -1,9 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { planStatus } from '../../src/utils/io.js';
3
2
  import { resolvePdPath } from '../../src/core/paths.js';
4
- import * as fs from 'fs';
5
-
6
- vi.mock('fs');
7
3
 
8
4
  describe('Path Anchoring Integration', () => {
9
5
  const workspaceDir = '/mock/workspace';
@@ -17,26 +13,8 @@ describe('Path Anchoring Integration', () => {
17
13
  expect(resolvePdPath(workspaceDir, 'PROFILE')).toBe(expected);
18
14
  });
19
15
 
20
- it('should resolve PLAN.md at the project root', () => {
21
- const expected = '/mock/workspace/PLAN.md';
22
- expect(resolvePdPath(workspaceDir, 'PLAN')).toBe(expected);
23
- });
24
-
25
16
  it('should resolve AGENT_SCORECARD.json inside .state/', () => {
26
17
  const expected = '/mock/workspace/.state/AGENT_SCORECARD.json';
27
18
  expect(resolvePdPath(workspaceDir, 'AGENT_SCORECARD')).toBe(expected);
28
19
  });
29
-
30
- it('planStatus should look for PLAN.md in the root', () => {
31
- const rootPlanPath = '/mock/workspace/PLAN.md';
32
- vi.mocked(fs.existsSync).mockImplementation((p) => p === rootPlanPath);
33
- vi.mocked(fs.readFileSync).mockReturnValue('STATUS: READY');
34
-
35
- const status = planStatus(workspaceDir);
36
-
37
- expect(status).toBe('READY');
38
- expect(fs.existsSync).toHaveBeenCalledWith(rootPlanPath);
39
- // Verify it does NOT look in docs/
40
- expect(fs.existsSync).not.toHaveBeenCalledWith(expect.stringContaining('docs/PLAN.md'));
41
- });
42
20
  });
@@ -65,8 +65,8 @@ describe('WorkspaceContext', () => {
65
65
 
66
66
  // PROFILE is at .principles/PROFILE.json
67
67
  expect(wctx.resolve('PROFILE')).toBe(path.join(workspaceDir, '.principles', 'PROFILE.json'));
68
- // PLAN is at root
69
- expect(wctx.resolve('PLAN')).toBe(path.join(workspaceDir, 'PLAN.md'));
68
+ // THINKING_OS is at .principles/THINKING_OS.md
69
+ expect(wctx.resolve('THINKING_OS')).toBe(path.join(workspaceDir, '.principles', 'THINKING_OS.md'));
70
70
  });
71
71
 
72
72
  it('should support explicit disposal from cache', () => {
@@ -94,7 +94,6 @@ describe('PRI-212 plugin core anti-growth guard', () => {
94
94
  'external-training-contract.ts',
95
95
  'merge-gate-audit.ts',
96
96
  'shadow-observation-registry.ts',
97
- 'confirm-first-gate.ts',
98
97
  'control-ui-db.ts',
99
98
  'thinking-models.ts',
100
99
  'pd-task-reconciler.ts',
@@ -0,0 +1,188 @@
1
+ /**
2
+ * PRI-286: Verify confirm-first gate has been fully removed from live paths.
3
+ *
4
+ * These tests prove that:
5
+ * 1. The confirm-first-gate module no longer exists as an importable live module
6
+ * 2. gate.ts does not call any confirm-first function
7
+ * 3. prompt.ts does not import any confirm-first function
8
+ * 4. gate-block-helper does not output confirm-first specific block messages
9
+ * 5. confirm_first_gate does not appear in DEFAULT_FEATURE_FLAGS
10
+ * 6. Default PD installation does not block mutating tools due to PLAN.md absence
11
+ * 7. PLAN.md is not a canonical PD path (paths.ts, path-resolver.ts, env.ts, migration.ts)
12
+ * 8. confirm-first event types and state store are fully deleted
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+
19
+ // __dirname = packages/openclaw-plugin/tests/hooks → go up 4 to monorepo root
20
+ // (hooks → tests → openclaw-plugin → packages → monorepo-root)
21
+ const ROOT = path.resolve(__dirname, '..', '..', '..', '..');
22
+
23
+ describe('PRI-286: Confirm-first gate removal verification', () => {
24
+ it('confirm-first-gate.ts source file has been deleted', () => {
25
+ const gatePath = path.join(ROOT, 'packages/openclaw-plugin/src/core/confirm-first-gate.ts');
26
+ expect(fs.existsSync(gatePath)).toBe(false);
27
+ });
28
+
29
+ it('gate.ts does not import from confirm-first-gate', async () => {
30
+ const gateSource = fs.readFileSync(
31
+ path.join(ROOT, 'packages/openclaw-plugin/src/hooks/gate.ts'),
32
+ 'utf8',
33
+ );
34
+ expect(gateSource).not.toContain('confirm-first-gate');
35
+ expect(gateSource).not.toContain('evaluateConfirmFirstGateSync');
36
+ expect(gateSource).not.toContain('confirm-first');
37
+ });
38
+
39
+ it('prompt.ts does not import from confirm-first-gate', async () => {
40
+ const promptSource = fs.readFileSync(
41
+ path.join(ROOT, 'packages/openclaw-plugin/src/hooks/prompt.ts'),
42
+ 'utf8',
43
+ );
44
+ expect(promptSource).not.toContain('confirm-first-gate');
45
+ expect(promptSource).not.toContain('detectApprovalMarker');
46
+ expect(promptSource).not.toContain('setConfirmFirstDirective');
47
+ expect(promptSource).not.toContain('setConfirmFirstApproval');
48
+ expect(promptSource).not.toContain('hydrateFromStore');
49
+ expect(promptSource).not.toContain('pruneStoreStaleRows');
50
+ expect(promptSource).not.toContain('setConfirmFirstStore');
51
+ expect(promptSource).not.toContain('resetConfirmFirst');
52
+ expect(promptSource).not.toContain('setConfirmFirstGateEnabled');
53
+ expect(promptSource).not.toContain('SqliteConfirmFirstStateStore');
54
+ expect(promptSource).not.toContain('confirm_first_gate');
55
+ });
56
+
57
+ it('gate-block-helper does not have confirm-first specific branch', () => {
58
+ const helperSource = fs.readFileSync(
59
+ path.join(ROOT, 'packages/openclaw-plugin/src/hooks/gate-block-helper.ts'),
60
+ 'utf8',
61
+ );
62
+ expect(helperSource).not.toContain('confirm-first-gate');
63
+ expect(helperSource).not.toContain('Confirm-First Gate Blocked');
64
+ expect(helperSource).not.toContain('confirm-first behavioral directive');
65
+ });
66
+
67
+ it('confirm_first_gate is not in DEFAULT_FEATURE_FLAGS', async () => {
68
+ const { DEFAULT_FEATURE_FLAGS } = await import('@principles/core/runtime-v2');
69
+ const ids = DEFAULT_FEATURE_FLAGS.map((f: { id: string }) => f.id);
70
+ expect(ids).not.toContain('confirm_first_gate');
71
+ });
72
+
73
+ it('no PLAN.md physical interception language in AGENTS.md templates', () => {
74
+ const templateDirs = [
75
+ path.join(ROOT, 'packages/openclaw-plugin/templates'),
76
+ path.join(ROOT, 'packages/create-principles-disciple/templates'),
77
+ path.join(ROOT, 'packages/create-principles-disciple/plugin/templates'),
78
+ ];
79
+
80
+ for (const dir of templateDirs) {
81
+ if (!fs.existsSync(dir)) continue;
82
+ const agentsFiles = findFiles(dir, 'AGENTS.md');
83
+ for (const file of agentsFiles) {
84
+ const content = fs.readFileSync(file, 'utf8');
85
+ // Must NOT contain physical interception language
86
+ expect(content, `${file} should not contain physical interception`).not.toContain('Physical interception');
87
+ expect(content, `${file} should not contain 物理拦截`).not.toContain('物理拦截');
88
+ expect(content, `${file} should not contain Single source of truth.*PLAN`).not.toMatch(/Single source of truth.*PLAN/i);
89
+ }
90
+ }
91
+ });
92
+
93
+ it('no mandatory PLAN.md STATUS:READY in THINKING_OS templates', () => {
94
+ const templateDirs = [
95
+ path.join(ROOT, 'packages/openclaw-plugin/templates'),
96
+ path.join(ROOT, 'packages/create-principles-disciple/templates'),
97
+ path.join(ROOT, 'packages/create-principles-disciple/plugin/templates'),
98
+ ];
99
+
100
+ for (const dir of templateDirs) {
101
+ if (!fs.existsSync(dir)) continue;
102
+ const thinkingFiles = findFiles(dir, 'THINKING_OS.md');
103
+ for (const file of thinkingFiles) {
104
+ const content = fs.readFileSync(file, 'utf8');
105
+ expect(content, `${file} should not require PLAN.md status: READY`).not.toContain('PLAN.md` (status: READY)');
106
+ expect(content, `${file} should not require PLAN.md(状态:READY)`).not.toContain('PLAN.md`(状态:READY)');
107
+ }
108
+ }
109
+ });
110
+
111
+ // ── Round 2: Canonical PLAN.md path removal ──
112
+
113
+ it('paths.ts does not contain PLAN: entry', () => {
114
+ const source = fs.readFileSync(
115
+ path.join(ROOT, 'packages/openclaw-plugin/src/core/paths.ts'),
116
+ 'utf8',
117
+ );
118
+ expect(source).not.toMatch(/PLAN:\s*'PLAN\.md'/);
119
+ });
120
+
121
+ it('path-resolver.ts does not contain PLAN key', () => {
122
+ const source = fs.readFileSync(
123
+ path.join(ROOT, 'packages/openclaw-plugin/src/core/path-resolver.ts'),
124
+ 'utf8',
125
+ );
126
+ expect(source).not.toContain("'PLAN':");
127
+ expect(source).not.toContain('"PLAN":');
128
+ });
129
+
130
+ it('env.ts CORE_FILES does not contain PLAN.md', () => {
131
+ const source = fs.readFileSync(
132
+ path.join(ROOT, 'packages/create-principles-disciple/src/utils/env.ts'),
133
+ 'utf8',
134
+ );
135
+ // Match 'PLAN.md' inside the CORE_FILES array — should not exist
136
+ expect(source).not.toMatch(/CORE_FILES\s*=\s*\[[\s\S]*?'PLAN\.md'/);
137
+ });
138
+
139
+ it('migration.ts does not migrate docs/PLAN.md', () => {
140
+ const source = fs.readFileSync(
141
+ path.join(ROOT, 'packages/openclaw-plugin/src/core/migration.ts'),
142
+ 'utf8',
143
+ );
144
+ expect(source).not.toContain("'PLAN.md'");
145
+ expect(source).not.toContain("newKey: 'PLAN'");
146
+ });
147
+
148
+ // ── Round 2: Event type and state store full deletion ──
149
+
150
+ it('event-types.ts does not contain runtime_v2_confirm_first_gate', () => {
151
+ const source = fs.readFileSync(
152
+ path.join(ROOT, 'packages/principles-core/src/runtime-v2/types/event-types.ts'),
153
+ 'utf8',
154
+ );
155
+ expect(source).not.toContain('runtime_v2_confirm_first_gate');
156
+ expect(source).not.toContain('RuntimeV2ConfirmFirstGate');
157
+ });
158
+
159
+ it('confirm-first state store source has been deleted', () => {
160
+ const storePath = path.join(
161
+ ROOT, 'packages/principles-core/src/runtime-v2/activation/sqlite-confirm-first-state-store.ts',
162
+ );
163
+ expect(fs.existsSync(storePath)).toBe(false);
164
+ });
165
+
166
+ it('confirm-first state store test has been deleted', () => {
167
+ const testPath = path.join(
168
+ ROOT, 'packages/principles-core/src/runtime-v2/__tests__/sqlite-confirm-first-state-store.test.ts',
169
+ );
170
+ expect(fs.existsSync(testPath)).toBe(false);
171
+ });
172
+ });
173
+
174
+ function findFiles(dir: string, filename: string): string[] {
175
+ const results: string[] = [];
176
+ function walk(d: string): void {
177
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
178
+ const full = path.join(d, entry.name);
179
+ if (entry.isDirectory()) {
180
+ walk(full);
181
+ } else if (entry.name === filename) {
182
+ results.push(full);
183
+ }
184
+ }
185
+ }
186
+ walk(dir);
187
+ return results;
188
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Regression test: write tools without file_path must still go through RuleHost.
3
+ *
4
+ * PRI-286 P1: After removing confirm-first gate, write tools (apply_patch, patch, etc.)
5
+ * that have no file_path/path/file/target param must NOT be silently allowed.
6
+ * They must use a synthetic path `<tool:${toolName}>` and still evaluate via RuleHost.
7
+ *
8
+ * Uses vi.hoisted + mock of WorkspaceContext to avoid isolation issues in full suite.
9
+ * WorkspaceContext is the key — in full suite, other test files initialize the real
10
+ * context which caches a real EventLogService that doesn't have our mock methods.
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+
15
+ // vi.hoisted ensures these are available to vi.mock factories at hoist time
16
+ const { mockEvaluate, mockEventLog, mockEvolution } = vi.hoisted(() => {
17
+ const mockEvaluate = vi.fn().mockReturnValue(undefined);
18
+ const mockEventLog = {
19
+ recordRuleHostEvaluated: vi.fn(),
20
+ recordRuleEnforced: vi.fn(),
21
+ recordRuleHostBlocked: vi.fn(),
22
+ recordRuleHostRequireApproval: vi.fn(),
23
+ recordRuleHostAutoCorrectProposed: vi.fn(),
24
+ recordRuleHostAutoCorrectApplied: vi.fn(),
25
+ recordGateBlock: vi.fn(),
26
+ recordSession: vi.fn(),
27
+ };
28
+ const mockEvolution = {
29
+ getTier: vi.fn().mockReturnValue(3),
30
+ getPoints: vi.fn().mockReturnValue(200),
31
+ };
32
+ return { mockEvaluate, mockEventLog, mockEvolution };
33
+ });
34
+
35
+ vi.mock('../../src/core/session-tracker.js', () => ({
36
+ getSession: vi.fn(() => ({ currentGfi: 0 })),
37
+ trackBlock: vi.fn(),
38
+ hasRecentThinking: vi.fn(() => false),
39
+ }));
40
+
41
+ vi.mock('../../src/core/evolution-engine.js', () => ({
42
+ getEvolutionEngine: vi.fn(() => mockEvolution),
43
+ }));
44
+
45
+ vi.mock('../../src/core/event-log.js', () => ({
46
+ EventLogService: { get: vi.fn(() => mockEventLog) },
47
+ }));
48
+
49
+ vi.mock('../../src/core/rule-host.js', () => ({
50
+ RuleHost: vi.fn(function(this: any, _stateDir: string, _logger: any) {
51
+ this.evaluate = mockEvaluate;
52
+ }),
53
+ }));
54
+
55
+ vi.mock('../../src/core/principle-tree-ledger.js', () => ({
56
+ loadLedger: vi.fn(),
57
+ listImplementationsByLifecycleState: vi.fn(() => []),
58
+ }));
59
+
60
+ // Mock WorkspaceContext to return a controlled instance with our mockEventLog.
61
+ // This prevents full-suite caching of real WorkspaceContext instances.
62
+ vi.mock('../../src/core/workspace-context.js', () => {
63
+ return {
64
+ WorkspaceContext: {
65
+ fromHookContext: vi.fn((ctx: any) => ({
66
+ workspaceDir: ctx.workspaceDir,
67
+ stateDir: ctx.workspaceDir + '/.state',
68
+ eventLog: mockEventLog,
69
+ trajectory: {
70
+ recordGateBlock: vi.fn(),
71
+ recordPainEvent: vi.fn(),
72
+ recordSession: vi.fn(),
73
+ },
74
+ config: {
75
+ get: vi.fn().mockReturnValue(undefined),
76
+ },
77
+ })),
78
+ },
79
+ };
80
+ });
81
+
82
+ // Dynamic import AFTER mocks are set up
83
+ const { handleBeforeToolCall } = await import('../../src/hooks/gate.js');
84
+
85
+ const workspaceDir = '/mock/workspace';
86
+ const sessionId = 'test-no-path';
87
+
88
+ describe('Write tools without file_path must go through RuleHost', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ mockEvaluate.mockReturnValue(undefined);
92
+ });
93
+
94
+ it('apply_patch with no path triggers RuleHost evaluate', () => {
95
+ mockEvaluate.mockReturnValue(undefined); // allow
96
+
97
+ const result = handleBeforeToolCall(
98
+ { toolName: 'apply_patch', params: { patch: 'some diff content' } } as any,
99
+ { workspaceDir, sessionId } as any,
100
+ );
101
+
102
+ // Should not be blocked (RuleHost returned undefined = allow)
103
+ expect(result).toBeUndefined();
104
+ // But RuleHost MUST have been called
105
+ expect(mockEvaluate).toHaveBeenCalledTimes(1);
106
+ // Verify synthetic path was used
107
+ const input = mockEvaluate.mock.calls[0][0];
108
+ expect(input.action.normalizedPath).toBe('<tool:apply_patch>');
109
+ });
110
+
111
+ it('apply_patch with no path: RuleHost block must return block', () => {
112
+ mockEvaluate.mockReturnValue({
113
+ decision: 'block',
114
+ matched: true,
115
+ reason: 'Test block: write tool without path',
116
+ ruleId: 'R_TEST',
117
+ principleId: 'P_TEST',
118
+ });
119
+
120
+ const result = handleBeforeToolCall(
121
+ { toolName: 'apply_patch', params: { patch: 'dangerous content' } } as any,
122
+ { workspaceDir, sessionId } as any,
123
+ );
124
+
125
+ expect(result).toBeDefined();
126
+ expect(result?.block).toBe(true);
127
+ expect(result?.blockReason).toContain('Test block: write tool without path');
128
+ expect(mockEvaluate).toHaveBeenCalledTimes(1);
129
+ expect(mockEvaluate.mock.calls[0][0].action.normalizedPath).toBe('<tool:apply_patch>');
130
+ });
131
+
132
+ it('patch tool with no path triggers RuleHost evaluate', () => {
133
+ mockEvaluate.mockReturnValue(undefined); // allow
134
+
135
+ const result = handleBeforeToolCall(
136
+ { toolName: 'patch', params: {} } as any,
137
+ { workspaceDir, sessionId } as any,
138
+ );
139
+
140
+ expect(result).toBeUndefined();
141
+ expect(mockEvaluate).toHaveBeenCalledTimes(1);
142
+ expect(mockEvaluate.mock.calls[0][0].action.normalizedPath).toBe('<tool:patch>');
143
+ });
144
+
145
+ it('Write tool with valid file_path still uses real path', () => {
146
+ mockEvaluate.mockReturnValue(undefined); // allow
147
+
148
+ const result = handleBeforeToolCall(
149
+ { toolName: 'write', params: { file_path: '/mock/workspace/src/app.ts', content: 'x' } } as any,
150
+ { workspaceDir, sessionId } as any,
151
+ );
152
+
153
+ expect(result).toBeUndefined();
154
+ expect(mockEvaluate).toHaveBeenCalledTimes(1);
155
+ expect(mockEvaluate.mock.calls[0][0].action.normalizedPath).toBe('src/app.ts');
156
+ });
157
+
158
+ it('bash with no file target still goes through RuleHost (existing behavior)', () => {
159
+ mockEvaluate.mockReturnValue(undefined); // allow
160
+
161
+ const result = handleBeforeToolCall(
162
+ { toolName: 'bash', params: { command: 'echo hello' } } as any,
163
+ { workspaceDir, sessionId } as any,
164
+ );
165
+
166
+ expect(result).toBeUndefined();
167
+ expect(mockEvaluate).toHaveBeenCalledTimes(1);
168
+ // Bash without file target uses the full command as path (existing heuristic)
169
+ const input = mockEvaluate.mock.calls[0][0];
170
+ expect(input.action.normalizedPath).toContain('echo hello');
171
+ });
172
+ });
@@ -1,255 +0,0 @@
1
- /**
2
- * Confirm-First Gate
3
- *
4
- * Hard enforcement for confirm-first Runtime V2 prompt activations.
5
- * When an owner-approved activation requires confirmation before coding,
6
- * this gate blocks mutating tools until the session has explicit owner approval.
7
- *
8
- * This is NOT a replacement for prompt injection — it's a hard fallback
9
- * for models that don't follow system prompt behavioral directives.
10
- *
11
- * Flow:
12
- * 1. Prompt hook (before_prompt_build) detects confirm-first directive and caches state
13
- * 2. Prompt hook detects user approval language and marks session approved
14
- * 3. Gate hook (before_tool_call) checks cached state synchronously
15
- */
16
-
17
- import { BASH_TOOLS_SET, WRITE_TOOLS } from '../constants/tools.js';
18
- import { SqliteConfirmFirstStateStore } from '@principles/core/runtime-v2';
19
-
20
- /** Per-session confirm-first state */
21
- interface ConfirmFirstSessionState {
22
- active: boolean;
23
- principleId?: string;
24
- }
25
-
26
- /** Size cap to prevent memory leaks from abandoned sessions */
27
- const MAX_SESSION_ENTRIES = 500;
28
-
29
- // TODO(PRI-268): stale directive cleanup
30
- const sessionDirectiveState = new Map<string, ConfirmFirstSessionState>();
31
- // TODO(PRI-267): per-task approval scope
32
- const sessionApprovalState = new Map<string, boolean>();
33
-
34
- let confirmFirstStore: SqliteConfirmFirstStateStore | null = null;
35
-
36
- export function setConfirmFirstStore(store: SqliteConfirmFirstStateStore | null): void {
37
- confirmFirstStore = store;
38
- }
39
-
40
- function evictOldestIfFull(map: Map<string, unknown>): void {
41
- if (map.size >= MAX_SESSION_ENTRIES) {
42
- const firstKey = map.keys().next().value;
43
- if (firstKey !== undefined) map.delete(firstKey);
44
- }
45
- }
46
-
47
- export interface ConfirmFirstGateResult {
48
- action: 'allow' | 'block' | 'skip';
49
- reason?: string;
50
- nextAction?: string;
51
- principleId?: string;
52
- }
53
-
54
- /**
55
- * Check if a tool is mutating (write, edit, delete, or mutating exec).
56
- */
57
- function isMutatingTool(toolName: string, params?: Record<string, unknown>): boolean {
58
- // Direct write/edit/delete tools are always mutating
59
- if (WRITE_TOOLS.has(toolName)) return true;
60
-
61
- // For exec/bash, only mutating if the command content is mutating
62
- if (BASH_TOOLS_SET.has(toolName)) {
63
- const command = String(params?.command || params?.args || '');
64
- if (!command) return false;
65
- return />\s*|>>\s*|\brm\b|\bmv\b|\bmkdir\b|\btouch\b|\bcp\s|\bsed\s+-i|\bchmod\b|\bchown\b|\bdel\s|\bRemove-Item\b|\bSet-Content\b|\bOut-File\b|\bNew-Item\b/.test(command);
66
- }
67
-
68
- return false;
69
- }
70
-
71
- /**
72
- * Detect if user message contains clear approval language.
73
- * Rejects negated forms (e.g., "don't proceed", "不同意", "确认一下").
74
- */
75
- export function detectApprovalMarker(message: string): boolean {
76
- const trimmed = message.trim();
77
-
78
- // Negation prefixes — reject if present before approval keywords
79
- const zhNegation = /不|别|暂不|先不|无法|不能|没准备好|还没|尚未/;
80
- const enNegation = /don'?t|not\s+ready|can'?t|won'?t|stop|hold|cannot|isn'?t|aren'?t|haven'?t|shouldn'?t/i;
81
-
82
- // Single-word Chinese markers require exact match (the word alone, not embedded in a sentence)
83
- const zhExactMarkers = /^(?:确认|批准|同意|执行吧|开始执行)$/;
84
- // Multi-word Chinese markers
85
- const zhPhraseMarkers = /按计划执行|可以执行|就这么做|去执行|照.*做|没问题.*执行/;
86
-
87
- // English markers — unambiguous single-word approvals only
88
- const enMarkers = /\bapproved\b|\bgo\s*ahead\b|\blgtm\b/i;
89
- // English phrase markers — require explicit approval context
90
- const enPhraseMarkers = /\byes,?\s*(do\s+it|proceed|execute)\b|\bdo\s+it\b|\bproceed\s+with\s+the\s+plan\b|\bexecute\s+the\s+plan\b|\bplease\s+proceed\s+with\s+the\s+plan\b/i;
91
-
92
- // Check Chinese
93
- if (zhExactMarkers.test(trimmed) || zhPhraseMarkers.test(trimmed)) {
94
- // Reject if negation prefix present
95
- if (zhNegation.test(trimmed)) return false;
96
- return true;
97
- }
98
-
99
- // Check English
100
- if (enMarkers.test(trimmed) || enPhraseMarkers.test(trimmed)) {
101
- if (enNegation.test(trimmed)) return false;
102
- return true;
103
- }
104
-
105
- return false;
106
- }
107
-
108
- /**
109
- * Set confirm-first directive state for a session (called from prompt hook).
110
- */
111
- export function setConfirmFirstDirective(
112
- sessionId: string,
113
- active: boolean,
114
- principleId?: string,
115
- ): void {
116
- evictOldestIfFull(sessionDirectiveState);
117
- sessionDirectiveState.set(sessionId, { active, principleId });
118
- if (confirmFirstStore) {
119
- try {
120
- confirmFirstStore.upsertDirective(sessionId, active, principleId ?? null);
121
- } catch (storeErr) {
122
- console.warn(`[PD:ConfirmFirst] Store write failed for directive (session=${sessionId}), degraded to cache-only: ${String(storeErr)}`);
123
- }
124
- }
125
- }
126
-
127
- /**
128
- * Mark a session as approved (called from prompt hook when approval detected).
129
- */
130
- export function setConfirmFirstApproval(sessionId: string): void {
131
- evictOldestIfFull(sessionApprovalState);
132
- sessionApprovalState.set(sessionId, true);
133
- if (confirmFirstStore) {
134
- try {
135
- confirmFirstStore.upsertApproval(sessionId);
136
- } catch (storeErr) {
137
- console.warn(`[PD:ConfirmFirst] Store write failed for approval (session=${sessionId}), degraded to cache-only: ${String(storeErr)}`);
138
- }
139
- }
140
- }
141
-
142
- /**
143
- * Synchronous gate evaluation — checks cached state only.
144
- * Called from before_tool_call hook (must be synchronous).
145
- */
146
- export function evaluateConfirmFirstGateSync(
147
- sessionId: string | undefined,
148
- toolName: string,
149
- params: Record<string, unknown> | undefined,
150
- ): ConfirmFirstGateResult {
151
- if (!sessionId) return { action: 'skip' };
152
-
153
- // 1. Check if session is already approved
154
- if (sessionApprovalState.get(sessionId)) {
155
- return { action: 'allow' };
156
- }
157
-
158
- // 2. Check if confirm-first directive is active for this session
159
- const directive = sessionDirectiveState.get(sessionId);
160
- if (!directive?.active) {
161
- return { action: 'skip' };
162
- }
163
-
164
- // 3. Check if tool is mutating
165
- if (!isMutatingTool(toolName, params)) {
166
- return { action: 'allow' };
167
- }
168
-
169
- // 4. Block: mutating tool with active confirm-first and no approval
170
- return {
171
- action: 'block',
172
- reason: 'confirm_first_required',
173
- nextAction:
174
- 'Summarize requirements, list ambiguities, propose a plan, and wait for explicit owner approval before mutating files.',
175
- principleId: directive.principleId,
176
- };
177
- }
178
-
179
- /**
180
- * Reset state for a session (e.g., on /reset).
181
- */
182
- export function resetConfirmFirst(sessionId: string): void {
183
- sessionDirectiveState.delete(sessionId);
184
- sessionApprovalState.delete(sessionId);
185
- if (confirmFirstStore) {
186
- try {
187
- confirmFirstStore.deleteState(sessionId);
188
- } catch (storeErr) {
189
- console.warn(`[PD:ConfirmFirst] Store delete failed for session=${sessionId}: ${String(storeErr)}`);
190
- }
191
- }
192
- }
193
-
194
- /**
195
- * Check if a session has been approved (for testing).
196
- */
197
- export function isSessionApproved(sessionId: string): boolean {
198
- return sessionApprovalState.get(sessionId) === true;
199
- }
200
-
201
- /**
202
- * Check if a session has an active directive (for testing).
203
- */
204
- export function hasActiveDirective(sessionId: string): boolean {
205
- return sessionDirectiveState.get(sessionId)?.active === true;
206
- }
207
-
208
- /**
209
- * Clear all state (for testing).
210
- */
211
- export function clearAllConfirmFirstState(): void {
212
- sessionDirectiveState.clear();
213
- sessionApprovalState.clear();
214
- if (confirmFirstStore) {
215
- try {
216
- confirmFirstStore.deleteAllState();
217
- } catch (storeErr) {
218
- console.warn(`[PD:ConfirmFirst] Store clearAll failed: ${String(storeErr)}`);
219
- }
220
- }
221
- }
222
-
223
- export function hydrateFromStore(sessionId: string): void {
224
- if (!confirmFirstStore) return;
225
- if (sessionDirectiveState.has(sessionId)) return;
226
-
227
- try {
228
- const record = confirmFirstStore.getState(sessionId);
229
- if (!record) return;
230
-
231
- evictOldestIfFull(sessionDirectiveState);
232
- sessionDirectiveState.set(sessionId, {
233
- active: record.directiveActive,
234
- principleId: record.directivePrincipleId ?? undefined,
235
- });
236
-
237
- if (record.approvalActive) {
238
- evictOldestIfFull(sessionApprovalState);
239
- sessionApprovalState.set(sessionId, true);
240
- }
241
- } catch (storeErr) {
242
- console.warn(`[PD:ConfirmFirst] Store hydration failed for session=${sessionId}: ${String(storeErr)}`);
243
- }
244
- }
245
-
246
- export function pruneStoreStaleRows(): number {
247
- if (!confirmFirstStore) return 0;
248
- try {
249
- return confirmFirstStore.pruneStaleRows();
250
- } catch (storeErr) {
251
- console.warn(`[PD:ConfirmFirst] Store pruning failed: ${String(storeErr)}`);
252
- return 0;
253
- }
254
- }
255
-