principles-disciple 1.11.0 → 1.13.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/CHANGELOG.md +26 -0
- package/README.md +66 -0
- package/esbuild.config.js +1 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -3
- package/run-nocturnal.mjs +30 -0
- package/scripts/db-migrate.mjs +170 -0
- package/scripts/sync-plugin.mjs +250 -6
- package/src/commands/archive-impl.ts +136 -0
- package/src/commands/capabilities.ts +4 -2
- package/src/commands/context.ts +5 -1
- package/src/commands/disable-impl.ts +151 -0
- package/src/commands/evolution-status.ts +64 -19
- package/src/commands/export.ts +8 -6
- package/src/commands/focus.ts +8 -20
- package/src/commands/nocturnal-review.ts +5 -7
- package/src/commands/nocturnal-rollout.ts +1 -12
- package/src/commands/nocturnal-train.ts +21 -47
- package/src/commands/pain.ts +10 -5
- package/src/commands/principle-rollback.ts +4 -2
- package/src/commands/promote-impl.ts +274 -0
- package/src/commands/rollback-impl.ts +234 -0
- package/src/commands/rollback.ts +6 -3
- package/src/commands/samples.ts +2 -0
- package/src/commands/thinking-os.ts +3 -4
- package/src/commands/workflow-debug.ts +2 -1
- package/src/config/errors.ts +1 -0
- package/src/core/AGENTS.md +34 -0
- package/src/core/adaptive-thresholds.ts +4 -3
- package/src/core/code-implementation-storage.ts +241 -0
- package/src/core/config.ts +5 -2
- package/src/core/control-ui-db.ts +29 -10
- package/src/core/detection-funnel.ts +12 -7
- package/src/core/diagnostician-task-store.ts +156 -0
- package/src/core/dictionary.ts +4 -4
- package/src/core/empathy-keyword-matcher.ts +7 -3
- package/src/core/empathy-types.ts +13 -2
- package/src/core/event-log.ts +14 -6
- package/src/core/evolution-engine.ts +27 -31
- package/src/core/evolution-logger.ts +3 -2
- package/src/core/evolution-reducer.ts +110 -31
- package/src/core/evolution-types.ts +10 -0
- package/src/core/external-training-contract.ts +1 -0
- package/src/core/focus-history.ts +38 -24
- package/src/core/hygiene/tracker.ts +10 -6
- package/src/core/init.ts +5 -2
- package/src/core/migration.ts +3 -3
- package/src/core/model-deployment-registry.ts +6 -4
- package/src/core/model-training-registry.ts +5 -3
- package/src/core/nocturnal-arbiter.ts +13 -14
- package/src/core/nocturnal-artifact-lineage.ts +117 -0
- package/src/core/nocturnal-artificer.ts +257 -0
- package/src/core/nocturnal-candidate-scoring.ts +4 -2
- package/src/core/nocturnal-compliance.ts +67 -19
- package/src/core/nocturnal-dataset.ts +95 -2
- package/src/core/nocturnal-executability.ts +2 -3
- package/src/core/nocturnal-export.ts +6 -3
- package/src/core/nocturnal-rule-implementation-validator.ts +245 -0
- package/src/core/nocturnal-trajectory-extractor.ts +10 -3
- package/src/core/nocturnal-trinity.ts +319 -61
- package/src/core/pain-context-extractor.ts +29 -15
- package/src/core/pain.ts +7 -5
- package/src/core/path-resolver.ts +16 -15
- package/src/core/paths.ts +2 -1
- package/src/core/pd-task-reconciler.ts +463 -0
- package/src/core/pd-task-service.ts +42 -0
- package/src/core/pd-task-store.ts +77 -0
- package/src/core/pd-task-types.ts +128 -0
- package/src/core/principle-internalization/deprecated-readiness.ts +91 -0
- package/src/core/principle-internalization/internalization-routing-policy.ts +208 -0
- package/src/core/principle-internalization/lifecycle-metrics.ts +149 -0
- package/src/core/principle-internalization/lifecycle-read-model.ts +243 -0
- package/src/core/principle-internalization/lifecycle-refresh.ts +11 -0
- package/src/core/principle-internalization/principle-lifecycle-service.ts +167 -0
- package/src/core/principle-training-state.ts +95 -370
- package/src/core/principle-tree-ledger.ts +733 -0
- package/src/core/principle-tree-migration.ts +195 -0
- package/src/core/profile.ts +3 -1
- package/src/core/promotion-gate.ts +14 -18
- package/src/core/replay-engine.ts +562 -0
- package/src/core/risk-calculator.ts +6 -4
- package/src/core/rule-host-helpers.ts +39 -0
- package/src/core/rule-host-types.ts +82 -0
- package/src/core/rule-host.ts +245 -0
- package/src/core/rule-implementation-runtime.ts +38 -0
- package/src/core/schema/db-types.ts +16 -0
- package/src/core/schema/index.ts +26 -0
- package/src/core/schema/migration-runner.ts +207 -0
- package/src/core/schema/migrations/001-init-trajectory.ts +211 -0
- package/src/core/schema/migrations/002-init-central.ts +122 -0
- package/src/core/schema/migrations/003-init-workflow.ts +55 -0
- package/src/core/schema/migrations/004-add-thinking-and-gfi.ts +74 -0
- package/src/core/schema/migrations/index.ts +31 -0
- package/src/core/schema/schema-definitions.ts +650 -0
- package/src/core/session-tracker.ts +6 -4
- package/src/core/shadow-observation-registry.ts +6 -3
- package/src/core/system-logger.ts +2 -2
- package/src/core/thinking-models.ts +182 -46
- package/src/core/thinking-os-parser.ts +156 -0
- package/src/core/training-program.ts +7 -7
- package/src/core/trajectory.ts +42 -36
- package/src/core/workspace-context.ts +77 -11
- package/src/core/workspace-dir-validation.ts +152 -0
- package/src/hooks/AGENTS.md +31 -0
- package/src/hooks/bash-risk.ts +3 -1
- package/src/hooks/edit-verification.ts +9 -5
- package/src/hooks/gate-block-helper.ts +5 -1
- package/src/hooks/gate.ts +152 -5
- package/src/hooks/gfi-gate.ts +9 -2
- package/src/hooks/lifecycle-routing.ts +124 -0
- package/src/hooks/lifecycle.ts +12 -12
- package/src/hooks/llm.ts +17 -109
- package/src/hooks/message-sanitize.ts +5 -3
- package/src/hooks/pain.ts +19 -15
- package/src/hooks/progressive-trust-gate.ts +7 -1
- package/src/hooks/prompt.ts +169 -60
- package/src/hooks/subagent.ts +5 -4
- package/src/hooks/thinking-checkpoint.ts +2 -0
- package/src/hooks/trajectory-collector.ts +15 -12
- package/src/http/principles-console-route.ts +31 -68
- package/src/i18n/commands.ts +2 -2
- package/src/index.ts +130 -40
- package/src/service/central-database.ts +131 -43
- package/src/service/central-health-service.ts +47 -0
- package/src/service/central-overview-service.ts +135 -0
- package/src/service/central-sync-service.ts +87 -0
- package/src/service/control-ui-query-service.ts +46 -36
- package/src/service/event-log-auditor.ts +261 -0
- package/src/service/evolution-query-service.ts +23 -22
- package/src/service/evolution-worker.ts +565 -261
- package/src/service/health-query-service.ts +213 -36
- package/src/service/nocturnal-runtime.ts +8 -4
- package/src/service/nocturnal-service.ts +503 -59
- package/src/service/nocturnal-target-selector.ts +5 -7
- package/src/service/runtime-summary-service.ts +2 -1
- package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +25 -336
- package/src/service/subagent-workflow/dynamic-timeout.ts +30 -0
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +48 -386
- package/src/service/subagent-workflow/index.ts +2 -0
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +169 -284
- package/src/service/subagent-workflow/runtime-direct-driver.ts +114 -16
- package/src/service/subagent-workflow/subagent-error-utils.ts +25 -0
- package/src/service/subagent-workflow/types.ts +9 -4
- package/src/service/subagent-workflow/workflow-manager-base.ts +573 -0
- package/src/service/subagent-workflow/workflow-store.ts +71 -11
- package/src/service/trajectory-service.ts +2 -1
- package/src/tools/critique-prompt.ts +1 -1
- package/src/tools/deep-reflect.ts +175 -209
- package/src/tools/model-index.ts +2 -1
- package/src/types/event-types.ts +2 -2
- package/src/types/principle-tree-schema.ts +29 -23
- package/src/utils/file-lock.ts +5 -3
- package/src/utils/io.ts +5 -2
- package/src/utils/nlp.ts +5 -46
- package/src/utils/node-vm-polyfill.ts +11 -0
- package/src/utils/plugin-logger.ts +2 -0
- package/src/utils/retry.ts +572 -0
- package/src/utils/subagent-probe.ts +1 -1
- package/templates/langs/en/core/AGENTS.md +0 -13
- package/templates/langs/en/core/SOUL.md +1 -31
- package/templates/langs/en/core/TOOLS.md +0 -4
- package/templates/langs/en/principles/THINKING_OS.md +77 -0
- package/templates/langs/en/skills/admin/SKILL.md +0 -1
- package/templates/langs/en/skills/evolution-framework-update/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-diagnostician/SKILL.md +18 -5
- package/templates/langs/zh/core/AGENTS.md +0 -22
- package/templates/langs/zh/core/SOUL.md +1 -31
- package/templates/langs/zh/core/TOOLS.md +0 -4
- package/templates/langs/zh/principles/THINKING_OS.md +77 -0
- package/templates/langs/zh/skills/admin/SKILL.md +0 -1
- package/templates/langs/zh/skills/evolution-framework-update/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +25 -4
- package/tests/commands/evolution-status.test.ts +119 -0
- package/tests/commands/implementation-lifecycle.test.ts +362 -0
- package/tests/core/code-implementation-storage.test.ts +398 -0
- package/tests/core/evolution-reducer.detector-metadata.test.ts +28 -28
- package/tests/core/nocturnal-artifact-lineage.test.ts +53 -0
- package/tests/core/nocturnal-artificer.test.ts +241 -0
- package/tests/core/nocturnal-compliance-p-principles.test.ts +133 -0
- package/tests/core/nocturnal-rule-implementation-validator.test.ts +127 -0
- package/tests/core/pd-task-store.test.ts +126 -0
- package/tests/core/principle-internalization/deprecated-readiness.test.ts +193 -0
- package/tests/core/principle-internalization/internalization-routing-policy.test.ts +212 -0
- package/tests/core/principle-internalization/lifecycle-metrics.test.ts +350 -0
- package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +211 -0
- package/tests/core/principle-training-state.test.ts +228 -1
- package/tests/core/principle-tree-ledger.test.ts +423 -0
- package/tests/core/regression-v1-9-1.test.ts +265 -0
- package/tests/core/replay-engine.test.ts +234 -0
- package/tests/core/rule-host-helpers.test.ts +120 -0
- package/tests/core/rule-host.test.ts +389 -0
- package/tests/core/rule-implementation-runtime.test.ts +64 -0
- package/tests/core/workspace-context.test.ts +53 -0
- package/tests/core/workspace-dir-validation.test.ts +272 -0
- package/tests/hooks/gate-rule-host-pipeline.test.ts +385 -0
- package/tests/hooks/pain.test.ts +74 -10
- package/tests/hooks/prompt.test.ts +63 -1
- package/tests/integration/principle-lifecycle.e2e.test.ts +197 -0
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +211 -0
- package/tests/service/data-endpoints-regression.test.ts +834 -0
- package/tests/service/evolution-worker.test.ts +0 -123
- package/tests/service/nocturnal-service-code-candidate.test.ts +330 -0
- package/tests/utils/nlp.test.ts +1 -19
- package/tests/utils/retry.test.ts +327 -0
- package/ui/src/App.tsx +1 -1
- package/ui/src/api.ts +4 -0
- package/ui/src/charts.tsx +366 -0
- package/ui/src/components/WorkspaceConfig.tsx +107 -75
- package/ui/src/i18n/ui.ts +89 -31
- package/ui/src/pages/EvolutionPage.tsx +1 -1
- package/ui/src/pages/OverviewPage.tsx +441 -81
- package/ui/src/pages/ThinkingModelsPage.tsx +287 -69
- package/ui/src/styles.css +43 -0
- package/ui/src/types.ts +17 -1
- package/src/agents/nocturnal-dreamer.md +0 -152
- package/src/agents/nocturnal-philosopher.md +0 -138
- package/src/agents/nocturnal-reflector.md +0 -126
- package/src/agents/nocturnal-scribe.md +0 -164
- package/templates/workspace/.principles/00-kernel.md +0 -51
- package/templates/workspace/.principles/DECISION_POLICY.json +0 -44
- package/templates/workspace/.principles/PRINCIPLES.md +0 -20
- package/templates/workspace/.principles/PROFILE.json +0 -54
- package/templates/workspace/.principles/PROFILE.schema.json +0 -56
- package/templates/workspace/.principles/THINKING_OS.md +0 -64
- package/templates/workspace/.principles/THINKING_OS_ARCHIVE.md +0 -7
- package/templates/workspace/.principles/THINKING_OS_CANDIDATES.md +0 -9
- package/templates/workspace/.principles/models/_INDEX.md +0 -27
- package/templates/workspace/.principles/models/first_principles.md +0 -62
- package/templates/workspace/.principles/models/marketing_4p.md +0 -52
- package/templates/workspace/.principles/models/porter_five.md +0 -63
- package/templates/workspace/.principles/models/swot.md +0 -60
- package/templates/workspace/.principles/models/user_story_map.md +0 -63
- package/templates/workspace/.state/WORKBOARD.json +0 -4
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
parseArtificerOutput,
|
|
7
|
+
resolveArtificerTargetRule,
|
|
8
|
+
shouldRunArtificer,
|
|
9
|
+
} from '../../src/core/nocturnal-artificer.js';
|
|
10
|
+
import { saveLedger, type LedgerPrinciple, type LedgerRule } from '../../src/core/principle-tree-ledger.js';
|
|
11
|
+
import type { NocturnalSessionSnapshot } from '../../src/core/nocturnal-trajectory-extractor.js';
|
|
12
|
+
import { safeRmDir } from '../test-utils.js';
|
|
13
|
+
|
|
14
|
+
function createPrinciple(overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
|
|
15
|
+
return {
|
|
16
|
+
id: 'P-001',
|
|
17
|
+
version: 1,
|
|
18
|
+
text: 'Use the right safety hook',
|
|
19
|
+
triggerPattern: 'gate',
|
|
20
|
+
action: 'align code fixes to the correct rule',
|
|
21
|
+
status: 'active',
|
|
22
|
+
priority: 'P1',
|
|
23
|
+
scope: 'general',
|
|
24
|
+
evaluability: 'deterministic',
|
|
25
|
+
valueScore: 0,
|
|
26
|
+
adherenceRate: 0,
|
|
27
|
+
painPreventedCount: 0,
|
|
28
|
+
derivedFromPainIds: [],
|
|
29
|
+
ruleIds: [],
|
|
30
|
+
conflictsWithPrincipleIds: [],
|
|
31
|
+
createdAt: '2026-04-08T00:00:00.000Z',
|
|
32
|
+
updatedAt: '2026-04-08T00:00:00.000Z',
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createRule(overrides: Partial<LedgerRule> = {}): LedgerRule {
|
|
38
|
+
return {
|
|
39
|
+
id: 'R-001',
|
|
40
|
+
version: 1,
|
|
41
|
+
name: 'Protect destructive writes',
|
|
42
|
+
description: 'Gate write operations on risky paths.',
|
|
43
|
+
type: 'gate',
|
|
44
|
+
triggerCondition: 'write risk path',
|
|
45
|
+
enforcement: 'block',
|
|
46
|
+
action: 'require approval for risky write',
|
|
47
|
+
principleId: 'P-001',
|
|
48
|
+
status: 'implemented',
|
|
49
|
+
coverageRate: 0,
|
|
50
|
+
falsePositiveRate: 0,
|
|
51
|
+
implementationIds: [],
|
|
52
|
+
createdAt: '2026-04-08T00:00:00.000Z',
|
|
53
|
+
updatedAt: '2026-04-08T00:00:00.000Z',
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createSnapshot(): NocturnalSessionSnapshot {
|
|
59
|
+
return {
|
|
60
|
+
sessionId: 'session-1',
|
|
61
|
+
startedAt: '2026-04-08T00:00:00.000Z',
|
|
62
|
+
updatedAt: '2026-04-08T00:10:00.000Z',
|
|
63
|
+
assistantTurns: [],
|
|
64
|
+
userTurns: [],
|
|
65
|
+
toolCalls: [
|
|
66
|
+
{
|
|
67
|
+
toolName: 'write',
|
|
68
|
+
outcome: 'failure',
|
|
69
|
+
filePath: 'src/risk.ts',
|
|
70
|
+
durationMs: 10,
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
errorType: 'blocked',
|
|
73
|
+
errorMessage: 'risky write',
|
|
74
|
+
createdAt: '2026-04-08T00:01:00.000Z',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
painEvents: [
|
|
78
|
+
{
|
|
79
|
+
source: 'gate',
|
|
80
|
+
score: 0.9,
|
|
81
|
+
severity: 'high',
|
|
82
|
+
reason: 'write operation touched a risky path without approval',
|
|
83
|
+
createdAt: '2026-04-08T00:02:00.000Z',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
gateBlocks: [
|
|
87
|
+
{
|
|
88
|
+
toolName: 'write',
|
|
89
|
+
filePath: 'src/risk.ts',
|
|
90
|
+
reason: 'risky write requires approval',
|
|
91
|
+
planStatus: 'DRAFT',
|
|
92
|
+
createdAt: '2026-04-08T00:03:00.000Z',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
stats: {
|
|
96
|
+
totalAssistantTurns: 0,
|
|
97
|
+
totalToolCalls: 1,
|
|
98
|
+
totalPainEvents: 1,
|
|
99
|
+
totalGateBlocks: 1,
|
|
100
|
+
failureCount: 1,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('nocturnal-artificer', () => {
|
|
106
|
+
let tempDir: string;
|
|
107
|
+
let stateDir: string;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-artificer-'));
|
|
111
|
+
stateDir = path.join(tempDir, '.state');
|
|
112
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
safeRmDir(tempDir);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('selects the only rule for a principle without scoring ambiguity', () => {
|
|
120
|
+
saveLedger(stateDir, {
|
|
121
|
+
trainingStore: {},
|
|
122
|
+
tree: {
|
|
123
|
+
principles: {
|
|
124
|
+
'P-001': createPrinciple({ ruleIds: ['R-001'] }),
|
|
125
|
+
},
|
|
126
|
+
rules: {
|
|
127
|
+
'R-001': createRule(),
|
|
128
|
+
},
|
|
129
|
+
implementations: {},
|
|
130
|
+
metrics: {},
|
|
131
|
+
lastUpdated: '2026-04-08T00:00:00.000Z',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const resolution = resolveArtificerTargetRule(stateDir, 'P-001', createSnapshot());
|
|
136
|
+
|
|
137
|
+
expect(resolution).toMatchObject({
|
|
138
|
+
status: 'selected',
|
|
139
|
+
ruleId: 'R-001',
|
|
140
|
+
reason: 'single-rule',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('selects the highest-signal rule when one rule has deterministic evidence', () => {
|
|
145
|
+
saveLedger(stateDir, {
|
|
146
|
+
trainingStore: {},
|
|
147
|
+
tree: {
|
|
148
|
+
principles: {
|
|
149
|
+
'P-001': createPrinciple({ ruleIds: ['R-001', 'R-002'] }),
|
|
150
|
+
},
|
|
151
|
+
rules: {
|
|
152
|
+
'R-001': createRule(),
|
|
153
|
+
'R-002': createRule({
|
|
154
|
+
id: 'R-002',
|
|
155
|
+
name: 'Protect archive deletes',
|
|
156
|
+
description: 'Require approval for delete operations.',
|
|
157
|
+
triggerCondition: 'delete archive',
|
|
158
|
+
action: 'block unreviewed delete',
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
implementations: {},
|
|
162
|
+
metrics: {},
|
|
163
|
+
lastUpdated: '2026-04-08T00:00:00.000Z',
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const resolution = resolveArtificerTargetRule(stateDir, 'P-001', createSnapshot());
|
|
168
|
+
|
|
169
|
+
expect(resolution).toMatchObject({
|
|
170
|
+
status: 'selected',
|
|
171
|
+
ruleId: 'R-001',
|
|
172
|
+
reason: 'evidence-winner',
|
|
173
|
+
});
|
|
174
|
+
expect(resolution.scores[0]?.score).toBeGreaterThan(resolution.scores[1]?.score ?? 0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('skips Artificer routing when multiple rules tie on the same evidence', () => {
|
|
178
|
+
saveLedger(stateDir, {
|
|
179
|
+
trainingStore: {},
|
|
180
|
+
tree: {
|
|
181
|
+
principles: {
|
|
182
|
+
'P-001': createPrinciple({ ruleIds: ['R-001', 'R-002'] }),
|
|
183
|
+
},
|
|
184
|
+
rules: {
|
|
185
|
+
'R-001': createRule({
|
|
186
|
+
name: 'Protect risky write',
|
|
187
|
+
description: 'risky write requires approval',
|
|
188
|
+
triggerCondition: 'write risky',
|
|
189
|
+
action: 'require approval for risky write',
|
|
190
|
+
}),
|
|
191
|
+
'R-002': createRule({
|
|
192
|
+
id: 'R-002',
|
|
193
|
+
name: 'Protect risky write',
|
|
194
|
+
description: 'risky write requires approval',
|
|
195
|
+
triggerCondition: 'write risky',
|
|
196
|
+
action: 'require approval for risky write',
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
implementations: {},
|
|
200
|
+
metrics: {},
|
|
201
|
+
lastUpdated: '2026-04-08T00:00:00.000Z',
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const resolution = resolveArtificerTargetRule(stateDir, 'P-001', createSnapshot());
|
|
206
|
+
|
|
207
|
+
expect(resolution).toMatchObject({
|
|
208
|
+
status: 'skip',
|
|
209
|
+
reason: 'ambiguous-target-rule',
|
|
210
|
+
});
|
|
211
|
+
expect(shouldRunArtificer(createSnapshot(), resolution)).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('parses a structured code-candidate output contract with lineage metadata', () => {
|
|
215
|
+
const parsed = parseArtificerOutput(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
ruleId: 'R-001',
|
|
218
|
+
candidateSource: 'export const meta = {}; export function evaluate() {}',
|
|
219
|
+
helperUsage: ['getToolName', 'hasPlanFile'],
|
|
220
|
+
expectedDecision: 'requireApproval',
|
|
221
|
+
rationale: 'Target the risky write path guard.',
|
|
222
|
+
lineage: {
|
|
223
|
+
artifactKind: 'rule-implementation-candidate',
|
|
224
|
+
sourceSnapshotRef: 'snapshot-session-1',
|
|
225
|
+
sourcePainIds: [],
|
|
226
|
+
sourceGateBlockIds: [],
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(parsed).toMatchObject({
|
|
232
|
+
ruleId: 'R-001',
|
|
233
|
+
implementationType: 'code',
|
|
234
|
+
helperUsage: ['getToolName', 'hasPlanFile'],
|
|
235
|
+
expectedDecision: 'requireApproval',
|
|
236
|
+
lineage: {
|
|
237
|
+
artifactKind: 'rule-implementation-candidate',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for #216: P_* principle violation and opportunity detection
|
|
3
|
+
*
|
|
4
|
+
* Before this fix, detectOpportunity and detectViolation only handled T-01~T-09,
|
|
5
|
+
* causing all P_* principles to return false for both applicable and violated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { detectOpportunity, detectViolation } from '../../src/core/nocturnal-compliance.js';
|
|
10
|
+
import type { SessionEvents } from '../../src/core/nocturnal-compliance.js';
|
|
11
|
+
|
|
12
|
+
function makeSession(overrides: Partial<SessionEvents> = {}): SessionEvents {
|
|
13
|
+
return {
|
|
14
|
+
sessionId: 'test-session',
|
|
15
|
+
toolCalls: overrides.toolCalls ?? [],
|
|
16
|
+
painSignals: overrides.painSignals ?? [],
|
|
17
|
+
gateBlocks: overrides.gateBlocks ?? [],
|
|
18
|
+
userCorrections: overrides.userCorrections ?? [],
|
|
19
|
+
planApprovals: overrides.planApprovals ?? [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('#216: P_* principle detection', () => {
|
|
24
|
+
describe('detectOpportunity for P_* principles', () => {
|
|
25
|
+
it('returns applicable=true when session has pain signals', () => {
|
|
26
|
+
const session = makeSession({
|
|
27
|
+
painSignals: [{ source: 'tool_failure', score: 80, reason: 'write failed' }],
|
|
28
|
+
});
|
|
29
|
+
const result = detectOpportunity('P_001', session);
|
|
30
|
+
expect(result.applicable).toBe(true);
|
|
31
|
+
expect(result.reason).toContain('pain signal');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns applicable=true when session has tool failures', () => {
|
|
35
|
+
const session = makeSession({
|
|
36
|
+
toolCalls: [{ toolName: 'write', filePath: 'test.txt', outcome: 'failure', errorMessage: 'disk full' }],
|
|
37
|
+
});
|
|
38
|
+
const result = detectOpportunity('P_042', session);
|
|
39
|
+
expect(result.applicable).toBe(true);
|
|
40
|
+
expect(result.reason).toContain('tool failure');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns applicable=true when session has gate blocks', () => {
|
|
44
|
+
const session = makeSession({
|
|
45
|
+
gateBlocks: [{ toolName: 'bash', reason: 'high risk operation' }],
|
|
46
|
+
});
|
|
47
|
+
const result = detectOpportunity('P_065', session);
|
|
48
|
+
expect(result.applicable).toBe(true);
|
|
49
|
+
expect(result.reason).toContain('gate block');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns applicable=false when session has no negative signals', () => {
|
|
53
|
+
const session = makeSession({
|
|
54
|
+
toolCalls: [{ toolName: 'read', filePath: 'test.txt', outcome: 'success' }],
|
|
55
|
+
});
|
|
56
|
+
const result = detectOpportunity('P_001', session);
|
|
57
|
+
expect(result.applicable).toBe(false);
|
|
58
|
+
expect(result.reason).toContain('no pain/tool-failure/gate-block');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('detectViolation for P_* principles', () => {
|
|
63
|
+
it('returns violated=true when session has high pain signals (score >= 50)', () => {
|
|
64
|
+
const session = makeSession({
|
|
65
|
+
painSignals: [{ source: 'tool_failure', score: 80, reason: 'write failed' }],
|
|
66
|
+
});
|
|
67
|
+
const result = detectViolation('P_001', session);
|
|
68
|
+
expect(result.violated).toBe(true);
|
|
69
|
+
expect(result.reason).toContain('pain signal');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns violated=false when pain signals are low (score < 50)', () => {
|
|
73
|
+
const session = makeSession({
|
|
74
|
+
painSignals: [{ source: 'minor_issue', score: 30, reason: 'cosmetic' }],
|
|
75
|
+
toolCalls: [{ toolName: 'read', filePath: 'test.txt', outcome: 'success' }],
|
|
76
|
+
});
|
|
77
|
+
const result = detectViolation('P_001', session);
|
|
78
|
+
expect(result.violated).toBe(false);
|
|
79
|
+
expect(result.reason).toContain('no violation signals');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns violated=true when session has tool failures', () => {
|
|
83
|
+
const session = makeSession({
|
|
84
|
+
toolCalls: [
|
|
85
|
+
{ toolName: 'write', filePath: 'test.txt', outcome: 'failure', errorMessage: 'disk full' },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
const result = detectViolation('P_042', session);
|
|
89
|
+
expect(result.violated).toBe(true);
|
|
90
|
+
expect(result.reason).toContain('tool failure');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns violated=true when session has gate blocks', () => {
|
|
94
|
+
const session = makeSession({
|
|
95
|
+
gateBlocks: [{ toolName: 'bash', reason: 'high risk operation' }],
|
|
96
|
+
});
|
|
97
|
+
const result = detectViolation('P_065', session);
|
|
98
|
+
expect(result.violated).toBe(true);
|
|
99
|
+
expect(result.reason).toContain('gate block');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns violated=false for clean session with no negative signals', () => {
|
|
103
|
+
const session = makeSession({
|
|
104
|
+
toolCalls: [{ toolName: 'read', filePath: 'test.txt', outcome: 'success' }],
|
|
105
|
+
});
|
|
106
|
+
const result = detectViolation('P_001', session);
|
|
107
|
+
expect(result.violated).toBe(false);
|
|
108
|
+
expect(result.reason).toContain('no violation signals');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('T-* principles still work (regression check)', () => {
|
|
113
|
+
it('T-01 opportunity detected for edit operations', () => {
|
|
114
|
+
const session = makeSession({
|
|
115
|
+
toolCalls: [{ toolName: 'edit_file', filePath: 'test.ts', outcome: 'success' }],
|
|
116
|
+
});
|
|
117
|
+
const result = detectOpportunity('T-01', session);
|
|
118
|
+
expect(result.applicable).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('T-01 violation detected when editing without reading first', () => {
|
|
122
|
+
const session = makeSession({
|
|
123
|
+
toolCalls: [
|
|
124
|
+
{ toolName: 'edit_file', filePath: 'test.ts', outcome: 'failure', errorMessage: 'merge conflict' },
|
|
125
|
+
],
|
|
126
|
+
painSignals: [{ source: 'test.ts edit failed', score: 70, reason: 'Did not survey structure before editing' }],
|
|
127
|
+
});
|
|
128
|
+
const result = detectViolation('T-01', session);
|
|
129
|
+
// T-01 violation: edit without prior read, with pain signal matching file or pattern
|
|
130
|
+
expect(result.violated).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateRuleImplementationCandidate } from '../../src/core/nocturnal-rule-implementation-validator.js';
|
|
3
|
+
|
|
4
|
+
describe('nocturnal-rule-implementation-validator', () => {
|
|
5
|
+
it('accepts a RuleHost-compatible candidate and reports helper usage', () => {
|
|
6
|
+
const result = validateRuleImplementationCandidate(`
|
|
7
|
+
export const meta = {
|
|
8
|
+
name: 'risk-write-guard',
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
ruleId: 'R-001',
|
|
11
|
+
coversCondition: 'risky write'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function evaluate(input, helpers) {
|
|
15
|
+
if (helpers.isRiskPath() && helpers.getToolName() === 'write') {
|
|
16
|
+
return {
|
|
17
|
+
decision: 'requireApproval',
|
|
18
|
+
matched: true,
|
|
19
|
+
reason: 'Risk path write requires approval'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
decision: 'allow',
|
|
25
|
+
matched: false,
|
|
26
|
+
reason: 'not applicable'
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
expect(result.passed).toBe(true);
|
|
32
|
+
expect(result.failures).toEqual([]);
|
|
33
|
+
expect(result.helperUsage).toEqual(['isRiskPath', 'getToolName']);
|
|
34
|
+
expect(result.meta).toMatchObject({
|
|
35
|
+
ruleId: 'R-001',
|
|
36
|
+
name: 'risk-write-guard',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects forbidden APIs before compile/load checks', () => {
|
|
41
|
+
const result = validateRuleImplementationCandidate(`
|
|
42
|
+
export const meta = {
|
|
43
|
+
name: 'bad-guard',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
ruleId: 'R-001',
|
|
46
|
+
coversCondition: 'bad'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function evaluate() {
|
|
50
|
+
const fn = eval('1 + 1');
|
|
51
|
+
return {
|
|
52
|
+
decision: 'allow',
|
|
53
|
+
matched: false,
|
|
54
|
+
reason: String(fn)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
expect(result.passed).toBe(false);
|
|
60
|
+
expect(result.failures).toEqual(
|
|
61
|
+
expect.arrayContaining([
|
|
62
|
+
expect.objectContaining({
|
|
63
|
+
code: 'forbidden-api',
|
|
64
|
+
detail: 'eval',
|
|
65
|
+
}),
|
|
66
|
+
])
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects malformed exports when meta is missing', () => {
|
|
71
|
+
const result = validateRuleImplementationCandidate(`
|
|
72
|
+
export function evaluate() {
|
|
73
|
+
return {
|
|
74
|
+
decision: 'allow',
|
|
75
|
+
matched: false,
|
|
76
|
+
reason: 'missing meta'
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
expect(result.passed).toBe(false);
|
|
82
|
+
expect(result.failures).toEqual(
|
|
83
|
+
expect.arrayContaining([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
code: 'missing-meta',
|
|
86
|
+
}),
|
|
87
|
+
])
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects candidates whose evaluate result does not satisfy RuleHostResult', () => {
|
|
92
|
+
const result = validateRuleImplementationCandidate(`
|
|
93
|
+
export const meta = {
|
|
94
|
+
name: 'wrong-result-shape',
|
|
95
|
+
version: '1.0.0',
|
|
96
|
+
ruleId: 'R-001',
|
|
97
|
+
coversCondition: 'bad result'
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function evaluate() {
|
|
101
|
+
return {
|
|
102
|
+
decision: 'noop',
|
|
103
|
+
matched: 'sometimes',
|
|
104
|
+
reason: 42
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
`);
|
|
108
|
+
|
|
109
|
+
expect(result.passed).toBe(false);
|
|
110
|
+
expect(result.failures).toEqual(
|
|
111
|
+
expect.arrayContaining([
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
code: 'invalid-result',
|
|
114
|
+
detail: 'decision',
|
|
115
|
+
}),
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
code: 'invalid-result',
|
|
118
|
+
detail: 'matched',
|
|
119
|
+
}),
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
code: 'invalid-result',
|
|
122
|
+
detail: 'reason',
|
|
123
|
+
}),
|
|
124
|
+
])
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import {
|
|
6
|
+
readTasks,
|
|
7
|
+
writeTasks,
|
|
8
|
+
initTaskMeta,
|
|
9
|
+
updateSyncMeta,
|
|
10
|
+
} from '../../src/core/pd-task-store.js';
|
|
11
|
+
import type { PDTaskSpec } from '../../src/core/pd-task-types.js';
|
|
12
|
+
|
|
13
|
+
const TEST_TASK: PDTaskSpec = {
|
|
14
|
+
id: 'test-task',
|
|
15
|
+
name: 'PD Test Task',
|
|
16
|
+
description: 'A test task',
|
|
17
|
+
enabled: true,
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
schedule: { kind: 'every', everyMs: 3600000 },
|
|
20
|
+
execution: { promptTemplate: 'test', timeoutSeconds: 60 },
|
|
21
|
+
delivery: { mode: 'none' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('PDTaskStore', () => {
|
|
25
|
+
const tmpDir = path.join(os.tmpdir(), `pd-task-store-test-${Date.now()}-${Math.random()}`);
|
|
26
|
+
const stateDir = path.join(tmpDir, '.state');
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('readTasks', () => {
|
|
37
|
+
it('should return empty array when file does not exist', () => {
|
|
38
|
+
const tasks = readTasks(tmpDir);
|
|
39
|
+
expect(tasks).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return empty array for invalid JSON', () => {
|
|
43
|
+
const filePath = path.join(stateDir, 'pd_tasks.json');
|
|
44
|
+
fs.writeFileSync(filePath, 'not valid json', 'utf-8');
|
|
45
|
+
const tasks = readTasks(tmpDir);
|
|
46
|
+
expect(tasks).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return empty array for non-array JSON', () => {
|
|
50
|
+
const filePath = path.join(stateDir, 'pd_tasks.json');
|
|
51
|
+
fs.writeFileSync(filePath, JSON.stringify({ version: 1 }), 'utf-8');
|
|
52
|
+
const tasks = readTasks(tmpDir);
|
|
53
|
+
expect(tasks).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should read and parse valid task array', () => {
|
|
57
|
+
const filePath = path.join(stateDir, 'pd_tasks.json');
|
|
58
|
+
const tasks = [TEST_TASK];
|
|
59
|
+
fs.writeFileSync(filePath, JSON.stringify(tasks), 'utf-8');
|
|
60
|
+
const result = readTasks(tmpDir);
|
|
61
|
+
expect(result).toHaveLength(1);
|
|
62
|
+
expect(result[0].id).toBe('test-task');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('writeTasks', () => {
|
|
67
|
+
it('should create .state directory if missing', async () => {
|
|
68
|
+
const newDir = path.join(tmpDir, 'new-workspace');
|
|
69
|
+
await writeTasks(newDir, [TEST_TASK]);
|
|
70
|
+
const newStateDir = path.join(newDir, '.state');
|
|
71
|
+
expect(fs.existsSync(newStateDir)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should write tasks to pd_tasks.json', async () => {
|
|
75
|
+
await writeTasks(tmpDir, [TEST_TASK]);
|
|
76
|
+
const filePath = path.join(stateDir, 'pd_tasks.json');
|
|
77
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
78
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
79
|
+
expect(content).toHaveLength(1);
|
|
80
|
+
expect(content[0].id).toBe('test-task');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should use atomic write (tmp + rename)', async () => {
|
|
84
|
+
await writeTasks(tmpDir, [TEST_TASK]);
|
|
85
|
+
const filePath = path.join(stateDir, 'pd_tasks.json');
|
|
86
|
+
const tmpFile = filePath + '.tmp';
|
|
87
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('initTaskMeta', () => {
|
|
92
|
+
it('should initialize meta if missing', () => {
|
|
93
|
+
const task = { ...TEST_TASK };
|
|
94
|
+
const result = initTaskMeta(task);
|
|
95
|
+
expect(result.meta).toBeDefined();
|
|
96
|
+
expect(result.meta!.createdAtMs).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should preserve existing meta', () => {
|
|
100
|
+
const task = {
|
|
101
|
+
...TEST_TASK,
|
|
102
|
+
meta: { createdAtMs: 1000, autoDisabled: true },
|
|
103
|
+
};
|
|
104
|
+
const result = initTaskMeta(task);
|
|
105
|
+
expect(result.meta!.createdAtMs).toBe(1000);
|
|
106
|
+
expect(result.meta!.autoDisabled).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('updateSyncMeta', () => {
|
|
111
|
+
it('should update sync metadata', () => {
|
|
112
|
+
const task = { ...TEST_TASK };
|
|
113
|
+
const result = updateSyncMeta(task, 'ok', 'job-123');
|
|
114
|
+
expect(result.meta!.lastSyncedAtMs).toBeDefined();
|
|
115
|
+
expect(result.meta!.lastSyncStatus).toBe('ok');
|
|
116
|
+
expect(result.meta!.lastSyncedJobId).toBe('job-123');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should record error for failed sync', () => {
|
|
120
|
+
const task = { ...TEST_TASK };
|
|
121
|
+
const result = updateSyncMeta(task, 'error', undefined, 'Lock timeout');
|
|
122
|
+
expect(result.meta!.lastSyncStatus).toBe('error');
|
|
123
|
+
expect(result.meta!.lastSyncError).toBe('Lock timeout');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|