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.
Files changed (233) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -0
  3. package/esbuild.config.js +1 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -3
  6. package/run-nocturnal.mjs +30 -0
  7. package/scripts/db-migrate.mjs +170 -0
  8. package/scripts/sync-plugin.mjs +250 -6
  9. package/src/commands/archive-impl.ts +136 -0
  10. package/src/commands/capabilities.ts +4 -2
  11. package/src/commands/context.ts +5 -1
  12. package/src/commands/disable-impl.ts +151 -0
  13. package/src/commands/evolution-status.ts +64 -19
  14. package/src/commands/export.ts +8 -6
  15. package/src/commands/focus.ts +8 -20
  16. package/src/commands/nocturnal-review.ts +5 -7
  17. package/src/commands/nocturnal-rollout.ts +1 -12
  18. package/src/commands/nocturnal-train.ts +21 -47
  19. package/src/commands/pain.ts +10 -5
  20. package/src/commands/principle-rollback.ts +4 -2
  21. package/src/commands/promote-impl.ts +274 -0
  22. package/src/commands/rollback-impl.ts +234 -0
  23. package/src/commands/rollback.ts +6 -3
  24. package/src/commands/samples.ts +2 -0
  25. package/src/commands/thinking-os.ts +3 -4
  26. package/src/commands/workflow-debug.ts +2 -1
  27. package/src/config/errors.ts +1 -0
  28. package/src/core/AGENTS.md +34 -0
  29. package/src/core/adaptive-thresholds.ts +4 -3
  30. package/src/core/code-implementation-storage.ts +241 -0
  31. package/src/core/config.ts +5 -2
  32. package/src/core/control-ui-db.ts +29 -10
  33. package/src/core/detection-funnel.ts +12 -7
  34. package/src/core/diagnostician-task-store.ts +156 -0
  35. package/src/core/dictionary.ts +4 -4
  36. package/src/core/empathy-keyword-matcher.ts +7 -3
  37. package/src/core/empathy-types.ts +13 -2
  38. package/src/core/event-log.ts +14 -6
  39. package/src/core/evolution-engine.ts +27 -31
  40. package/src/core/evolution-logger.ts +3 -2
  41. package/src/core/evolution-reducer.ts +110 -31
  42. package/src/core/evolution-types.ts +10 -0
  43. package/src/core/external-training-contract.ts +1 -0
  44. package/src/core/focus-history.ts +38 -24
  45. package/src/core/hygiene/tracker.ts +10 -6
  46. package/src/core/init.ts +5 -2
  47. package/src/core/migration.ts +3 -3
  48. package/src/core/model-deployment-registry.ts +6 -4
  49. package/src/core/model-training-registry.ts +5 -3
  50. package/src/core/nocturnal-arbiter.ts +13 -14
  51. package/src/core/nocturnal-artifact-lineage.ts +117 -0
  52. package/src/core/nocturnal-artificer.ts +257 -0
  53. package/src/core/nocturnal-candidate-scoring.ts +4 -2
  54. package/src/core/nocturnal-compliance.ts +67 -19
  55. package/src/core/nocturnal-dataset.ts +95 -2
  56. package/src/core/nocturnal-executability.ts +2 -3
  57. package/src/core/nocturnal-export.ts +6 -3
  58. package/src/core/nocturnal-rule-implementation-validator.ts +245 -0
  59. package/src/core/nocturnal-trajectory-extractor.ts +10 -3
  60. package/src/core/nocturnal-trinity.ts +319 -61
  61. package/src/core/pain-context-extractor.ts +29 -15
  62. package/src/core/pain.ts +7 -5
  63. package/src/core/path-resolver.ts +16 -15
  64. package/src/core/paths.ts +2 -1
  65. package/src/core/pd-task-reconciler.ts +463 -0
  66. package/src/core/pd-task-service.ts +42 -0
  67. package/src/core/pd-task-store.ts +77 -0
  68. package/src/core/pd-task-types.ts +128 -0
  69. package/src/core/principle-internalization/deprecated-readiness.ts +91 -0
  70. package/src/core/principle-internalization/internalization-routing-policy.ts +208 -0
  71. package/src/core/principle-internalization/lifecycle-metrics.ts +149 -0
  72. package/src/core/principle-internalization/lifecycle-read-model.ts +243 -0
  73. package/src/core/principle-internalization/lifecycle-refresh.ts +11 -0
  74. package/src/core/principle-internalization/principle-lifecycle-service.ts +167 -0
  75. package/src/core/principle-training-state.ts +95 -370
  76. package/src/core/principle-tree-ledger.ts +733 -0
  77. package/src/core/principle-tree-migration.ts +195 -0
  78. package/src/core/profile.ts +3 -1
  79. package/src/core/promotion-gate.ts +14 -18
  80. package/src/core/replay-engine.ts +562 -0
  81. package/src/core/risk-calculator.ts +6 -4
  82. package/src/core/rule-host-helpers.ts +39 -0
  83. package/src/core/rule-host-types.ts +82 -0
  84. package/src/core/rule-host.ts +245 -0
  85. package/src/core/rule-implementation-runtime.ts +38 -0
  86. package/src/core/schema/db-types.ts +16 -0
  87. package/src/core/schema/index.ts +26 -0
  88. package/src/core/schema/migration-runner.ts +207 -0
  89. package/src/core/schema/migrations/001-init-trajectory.ts +211 -0
  90. package/src/core/schema/migrations/002-init-central.ts +122 -0
  91. package/src/core/schema/migrations/003-init-workflow.ts +55 -0
  92. package/src/core/schema/migrations/004-add-thinking-and-gfi.ts +74 -0
  93. package/src/core/schema/migrations/index.ts +31 -0
  94. package/src/core/schema/schema-definitions.ts +650 -0
  95. package/src/core/session-tracker.ts +6 -4
  96. package/src/core/shadow-observation-registry.ts +6 -3
  97. package/src/core/system-logger.ts +2 -2
  98. package/src/core/thinking-models.ts +182 -46
  99. package/src/core/thinking-os-parser.ts +156 -0
  100. package/src/core/training-program.ts +7 -7
  101. package/src/core/trajectory.ts +42 -36
  102. package/src/core/workspace-context.ts +77 -11
  103. package/src/core/workspace-dir-validation.ts +152 -0
  104. package/src/hooks/AGENTS.md +31 -0
  105. package/src/hooks/bash-risk.ts +3 -1
  106. package/src/hooks/edit-verification.ts +9 -5
  107. package/src/hooks/gate-block-helper.ts +5 -1
  108. package/src/hooks/gate.ts +152 -5
  109. package/src/hooks/gfi-gate.ts +9 -2
  110. package/src/hooks/lifecycle-routing.ts +124 -0
  111. package/src/hooks/lifecycle.ts +12 -12
  112. package/src/hooks/llm.ts +17 -109
  113. package/src/hooks/message-sanitize.ts +5 -3
  114. package/src/hooks/pain.ts +19 -15
  115. package/src/hooks/progressive-trust-gate.ts +7 -1
  116. package/src/hooks/prompt.ts +169 -60
  117. package/src/hooks/subagent.ts +5 -4
  118. package/src/hooks/thinking-checkpoint.ts +2 -0
  119. package/src/hooks/trajectory-collector.ts +15 -12
  120. package/src/http/principles-console-route.ts +31 -68
  121. package/src/i18n/commands.ts +2 -2
  122. package/src/index.ts +130 -40
  123. package/src/service/central-database.ts +131 -43
  124. package/src/service/central-health-service.ts +47 -0
  125. package/src/service/central-overview-service.ts +135 -0
  126. package/src/service/central-sync-service.ts +87 -0
  127. package/src/service/control-ui-query-service.ts +46 -36
  128. package/src/service/event-log-auditor.ts +261 -0
  129. package/src/service/evolution-query-service.ts +23 -22
  130. package/src/service/evolution-worker.ts +565 -261
  131. package/src/service/health-query-service.ts +213 -36
  132. package/src/service/nocturnal-runtime.ts +8 -4
  133. package/src/service/nocturnal-service.ts +503 -59
  134. package/src/service/nocturnal-target-selector.ts +5 -7
  135. package/src/service/runtime-summary-service.ts +2 -1
  136. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +25 -336
  137. package/src/service/subagent-workflow/dynamic-timeout.ts +30 -0
  138. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +48 -386
  139. package/src/service/subagent-workflow/index.ts +2 -0
  140. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +169 -284
  141. package/src/service/subagent-workflow/runtime-direct-driver.ts +114 -16
  142. package/src/service/subagent-workflow/subagent-error-utils.ts +25 -0
  143. package/src/service/subagent-workflow/types.ts +9 -4
  144. package/src/service/subagent-workflow/workflow-manager-base.ts +573 -0
  145. package/src/service/subagent-workflow/workflow-store.ts +71 -11
  146. package/src/service/trajectory-service.ts +2 -1
  147. package/src/tools/critique-prompt.ts +1 -1
  148. package/src/tools/deep-reflect.ts +175 -209
  149. package/src/tools/model-index.ts +2 -1
  150. package/src/types/event-types.ts +2 -2
  151. package/src/types/principle-tree-schema.ts +29 -23
  152. package/src/utils/file-lock.ts +5 -3
  153. package/src/utils/io.ts +5 -2
  154. package/src/utils/nlp.ts +5 -46
  155. package/src/utils/node-vm-polyfill.ts +11 -0
  156. package/src/utils/plugin-logger.ts +2 -0
  157. package/src/utils/retry.ts +572 -0
  158. package/src/utils/subagent-probe.ts +1 -1
  159. package/templates/langs/en/core/AGENTS.md +0 -13
  160. package/templates/langs/en/core/SOUL.md +1 -31
  161. package/templates/langs/en/core/TOOLS.md +0 -4
  162. package/templates/langs/en/principles/THINKING_OS.md +77 -0
  163. package/templates/langs/en/skills/admin/SKILL.md +0 -1
  164. package/templates/langs/en/skills/evolution-framework-update/SKILL.md +1 -1
  165. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +18 -5
  166. package/templates/langs/zh/core/AGENTS.md +0 -22
  167. package/templates/langs/zh/core/SOUL.md +1 -31
  168. package/templates/langs/zh/core/TOOLS.md +0 -4
  169. package/templates/langs/zh/principles/THINKING_OS.md +77 -0
  170. package/templates/langs/zh/skills/admin/SKILL.md +0 -1
  171. package/templates/langs/zh/skills/evolution-framework-update/SKILL.md +1 -1
  172. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +25 -4
  173. package/tests/commands/evolution-status.test.ts +119 -0
  174. package/tests/commands/implementation-lifecycle.test.ts +362 -0
  175. package/tests/core/code-implementation-storage.test.ts +398 -0
  176. package/tests/core/evolution-reducer.detector-metadata.test.ts +28 -28
  177. package/tests/core/nocturnal-artifact-lineage.test.ts +53 -0
  178. package/tests/core/nocturnal-artificer.test.ts +241 -0
  179. package/tests/core/nocturnal-compliance-p-principles.test.ts +133 -0
  180. package/tests/core/nocturnal-rule-implementation-validator.test.ts +127 -0
  181. package/tests/core/pd-task-store.test.ts +126 -0
  182. package/tests/core/principle-internalization/deprecated-readiness.test.ts +193 -0
  183. package/tests/core/principle-internalization/internalization-routing-policy.test.ts +212 -0
  184. package/tests/core/principle-internalization/lifecycle-metrics.test.ts +350 -0
  185. package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +211 -0
  186. package/tests/core/principle-training-state.test.ts +228 -1
  187. package/tests/core/principle-tree-ledger.test.ts +423 -0
  188. package/tests/core/regression-v1-9-1.test.ts +265 -0
  189. package/tests/core/replay-engine.test.ts +234 -0
  190. package/tests/core/rule-host-helpers.test.ts +120 -0
  191. package/tests/core/rule-host.test.ts +389 -0
  192. package/tests/core/rule-implementation-runtime.test.ts +64 -0
  193. package/tests/core/workspace-context.test.ts +53 -0
  194. package/tests/core/workspace-dir-validation.test.ts +272 -0
  195. package/tests/hooks/gate-rule-host-pipeline.test.ts +385 -0
  196. package/tests/hooks/pain.test.ts +74 -10
  197. package/tests/hooks/prompt.test.ts +63 -1
  198. package/tests/integration/principle-lifecycle.e2e.test.ts +197 -0
  199. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +211 -0
  200. package/tests/service/data-endpoints-regression.test.ts +834 -0
  201. package/tests/service/evolution-worker.test.ts +0 -123
  202. package/tests/service/nocturnal-service-code-candidate.test.ts +330 -0
  203. package/tests/utils/nlp.test.ts +1 -19
  204. package/tests/utils/retry.test.ts +327 -0
  205. package/ui/src/App.tsx +1 -1
  206. package/ui/src/api.ts +4 -0
  207. package/ui/src/charts.tsx +366 -0
  208. package/ui/src/components/WorkspaceConfig.tsx +107 -75
  209. package/ui/src/i18n/ui.ts +89 -31
  210. package/ui/src/pages/EvolutionPage.tsx +1 -1
  211. package/ui/src/pages/OverviewPage.tsx +441 -81
  212. package/ui/src/pages/ThinkingModelsPage.tsx +287 -69
  213. package/ui/src/styles.css +43 -0
  214. package/ui/src/types.ts +17 -1
  215. package/src/agents/nocturnal-dreamer.md +0 -152
  216. package/src/agents/nocturnal-philosopher.md +0 -138
  217. package/src/agents/nocturnal-reflector.md +0 -126
  218. package/src/agents/nocturnal-scribe.md +0 -164
  219. package/templates/workspace/.principles/00-kernel.md +0 -51
  220. package/templates/workspace/.principles/DECISION_POLICY.json +0 -44
  221. package/templates/workspace/.principles/PRINCIPLES.md +0 -20
  222. package/templates/workspace/.principles/PROFILE.json +0 -54
  223. package/templates/workspace/.principles/PROFILE.schema.json +0 -56
  224. package/templates/workspace/.principles/THINKING_OS.md +0 -64
  225. package/templates/workspace/.principles/THINKING_OS_ARCHIVE.md +0 -7
  226. package/templates/workspace/.principles/THINKING_OS_CANDIDATES.md +0 -9
  227. package/templates/workspace/.principles/models/_INDEX.md +0 -27
  228. package/templates/workspace/.principles/models/first_principles.md +0 -62
  229. package/templates/workspace/.principles/models/marketing_4p.md +0 -52
  230. package/templates/workspace/.principles/models/porter_five.md +0 -63
  231. package/templates/workspace/.principles/models/swot.md +0 -60
  232. package/templates/workspace/.principles/models/user_story_map.md +0 -63
  233. package/templates/workspace/.state/WORKBOARD.json +0 -4
@@ -0,0 +1,562 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { withLock } from '../utils/file-lock.js';
4
+ import { normalizePath, isRisky, planStatus as getPlanStatus } from '../utils/io.js';
5
+ import {
6
+ listSamplesByClassification,
7
+ loadSampleContent,
8
+ } from './nocturnal-dataset.js';
9
+ import {
10
+ getImplementationAssetRoot,
11
+ loadEntrySource,
12
+ } from './code-implementation-storage.js';
13
+ import type {
14
+ NocturnalDatasetRecord,
15
+ SampleClassification,
16
+ } from './nocturnal-dataset.js';
17
+ import { loadLedger } from './principle-tree-ledger.js';
18
+ import type { Implementation } from '../types/principle-tree-schema.js';
19
+ import type { RuleHostHelpers } from './rule-host-helpers.js';
20
+ import { createRuleHostHelpers } from './rule-host-helpers.js';
21
+ import type { RuleHostInput, RuleHostResult } from './rule-host-types.js';
22
+ import { loadRuleImplementationModule } from './rule-implementation-runtime.js';
23
+ import {
24
+ getNocturnalSessionSnapshot,
25
+ type NocturnalGateBlock,
26
+ type NocturnalSessionSnapshot,
27
+ type NocturnalToolCall,
28
+ } from './nocturnal-trajectory-extractor.js';
29
+ import { TrajectoryRegistry } from './trajectory.js';
30
+
31
+ export interface ReplaySample {
32
+ fingerprint: string;
33
+ classification: SampleClassification;
34
+ content: unknown;
35
+ expectedOutcome: {
36
+ shouldBlock?: boolean;
37
+ shouldPass?: boolean;
38
+ expectedPrinciple?: string;
39
+ };
40
+ record: NocturnalDatasetRecord;
41
+ }
42
+
43
+ export interface ReplayResult {
44
+ sampleFingerprint: string;
45
+ classification: SampleClassification;
46
+ passed: boolean;
47
+ reason?: string;
48
+ decision: string;
49
+ }
50
+
51
+ export interface ClassificationSummary {
52
+ total: number;
53
+ passed: number;
54
+ failed: number;
55
+ details: ReplayResult[];
56
+ }
57
+
58
+ export interface ReplayReport {
59
+ overallDecision: 'pass' | 'fail' | 'needs-review';
60
+ replayResults: {
61
+ painNegative: ClassificationSummary;
62
+ successPositive: ClassificationSummary;
63
+ principleAnchor: ClassificationSummary;
64
+ };
65
+ blockers: string[];
66
+ generatedAt: string;
67
+ implementationId: string;
68
+ sampleFingerprints: string[];
69
+ }
70
+
71
+ export interface CandidateEvaluator {
72
+ /* eslint-disable no-unused-vars -- Reason: interface method params are type signatures */
73
+ evaluate(sample: unknown): { passed: boolean; reason?: string; decision: string };
74
+ /* eslint-enable no-unused-vars */
75
+ }
76
+
77
+ export class ReplayEngine {
78
+ private readonly workspaceDir: string;
79
+ private readonly stateDir: string;
80
+
81
+ constructor(workspaceDir: string, stateDir: string) {
82
+ this.workspaceDir = workspaceDir;
83
+ this.stateDir = stateDir;
84
+ }
85
+
86
+ loadSamples(classifications: SampleClassification[]): ReplaySample[] {
87
+ const samples: ReplaySample[] = [];
88
+
89
+ for (const classification of classifications) {
90
+ const records = listSamplesByClassification(this.workspaceDir, classification);
91
+
92
+ for (const record of records) {
93
+ try {
94
+ const content = loadSampleContent(this.workspaceDir, record);
95
+ const expectedOutcome = this._deriveExpectedOutcome(record);
96
+
97
+ samples.push({
98
+ fingerprint: record.sampleFingerprint,
99
+ classification,
100
+ content,
101
+ expectedOutcome,
102
+ record,
103
+ });
104
+ } catch (err) {
105
+ console.warn(
106
+ `[ReplayEngine] Skipping sample ${record.sampleFingerprint}: ${String(err)}`
107
+ );
108
+ }
109
+ }
110
+ }
111
+
112
+ return samples;
113
+ }
114
+
115
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Public API method that delegates to evaluator, no instance state needed
116
+ runSingleSample(sample: ReplaySample, evaluator: CandidateEvaluator): ReplayResult {
117
+ const evaluation = evaluator.evaluate(sample);
118
+ return {
119
+ sampleFingerprint: sample.fingerprint,
120
+ classification: sample.classification,
121
+ passed: evaluation.passed,
122
+ reason: evaluation.passed ? undefined : evaluation.reason,
123
+ decision: evaluation.decision,
124
+ };
125
+ }
126
+
127
+ runReplay(
128
+ candidateImplId: string,
129
+ evaluator: CandidateEvaluator,
130
+ classifications?: SampleClassification[]
131
+ ): ReplayReport {
132
+ const selectedClassifications: SampleClassification[] = classifications ?? [
133
+ 'pain-negative',
134
+ 'success-positive',
135
+ 'principle-anchor',
136
+ ];
137
+
138
+ const samples = this.loadSamples(selectedClassifications);
139
+ const allResults = samples.map((sample) => this.runSingleSample(sample, evaluator));
140
+ const report = this._buildReport(candidateImplId, allResults);
141
+ this._persistReport(report);
142
+ return report;
143
+ }
144
+
145
+ runReplayForImplementation(
146
+ implementationId: string,
147
+ classifications?: SampleClassification[],
148
+ ): ReplayReport {
149
+ const implementation = this._getImplementationById(implementationId);
150
+ if (!implementation) {
151
+ throw new Error(`Implementation not found: ${implementationId}`);
152
+ }
153
+
154
+ const evaluator = this._createEvaluatorForImplementation(implementation);
155
+ return this.runReplay(implementationId, evaluator, classifications);
156
+ }
157
+
158
+ listReports(implementationId: string): ReplayReport[] {
159
+ const reportDir = path.join(
160
+ getImplementationAssetRoot(this.stateDir, implementationId),
161
+ 'replays'
162
+ );
163
+
164
+ if (!fs.existsSync(reportDir)) return [];
165
+
166
+ try {
167
+ const files = fs.readdirSync(reportDir).filter((file) => file.endsWith('.json'));
168
+ return files
169
+ .sort()
170
+ .reverse()
171
+ .map((file) => {
172
+ const content = fs.readFileSync(path.join(reportDir, file), 'utf-8');
173
+ return JSON.parse(content) as ReplayReport;
174
+ });
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ getLatestReport(implementationId: string): ReplayReport | null {
181
+ const reports = this.listReports(implementationId);
182
+ return reports.length > 0 ? reports[0] : null;
183
+ }
184
+
185
+ hasPassingReport(implementationId: string): boolean {
186
+ return this.listReports(implementationId).some((report) => report.overallDecision === 'pass');
187
+ }
188
+
189
+ private _getImplementationById(implementationId: string): Implementation | null {
190
+ const ledger = loadLedger(this.stateDir);
191
+ return ledger.tree.implementations[implementationId] ?? null;
192
+ }
193
+
194
+ private _createEvaluatorForImplementation(implementation: Implementation): CandidateEvaluator {
195
+ const sourceCode = loadEntrySource(this.stateDir, implementation.id);
196
+ if (!sourceCode) {
197
+ throw new Error(`Implementation asset entry source missing: ${implementation.id}`);
198
+ }
199
+
200
+ const moduleExports = loadRuleImplementationModule(sourceCode, implementation.id);
201
+ if (typeof moduleExports.evaluate !== 'function') {
202
+ throw new Error(`Implementation ${implementation.id} does not export evaluate().`);
203
+ }
204
+
205
+ /* eslint-disable no-unused-vars -- Reason: type-only parameters in type cast, not used at runtime */
206
+ const evaluate = moduleExports.evaluate as (
207
+ _input: RuleHostInput,
208
+ _helpers: RuleHostHelpers,
209
+ ) => RuleHostResult;
210
+ /* eslint-enable no-unused-vars */
211
+
212
+ return {
213
+ evaluate: (sample: unknown) => {
214
+ const replaySample = sample as ReplaySample;
215
+ const input = this._buildRuleHostInput(replaySample);
216
+ if (!input) {
217
+ return {
218
+ passed: false,
219
+ reason: `Could not build replay input for sample ${replaySample.fingerprint}.`,
220
+ decision: 'replay-input-missing',
221
+ };
222
+ }
223
+
224
+ const result = evaluate(input, createRuleHostHelpers(input));
225
+ return this._scoreEvaluation(replaySample, result);
226
+ },
227
+ };
228
+ }
229
+
230
+ private _buildRuleHostInput(sample: ReplaySample): RuleHostInput | null {
231
+ const snapshot = getNocturnalSessionSnapshot(
232
+ TrajectoryRegistry.get(this.workspaceDir),
233
+ sample.record.sessionId,
234
+ );
235
+ if (!snapshot) {
236
+ return null;
237
+ }
238
+
239
+ const toolCall = this._selectToolCall(snapshot, sample.classification);
240
+ if (!toolCall) {
241
+ return null;
242
+ }
243
+
244
+ const normalizedPath =
245
+ typeof toolCall.filePath === 'string' && toolCall.filePath.length > 0
246
+ ? normalizePath(toolCall.filePath, this.workspaceDir)
247
+ : null;
248
+ const matchedGateBlock = this._matchGateBlock(snapshot.gateBlocks, toolCall);
249
+
250
+ return {
251
+ action: {
252
+ toolName: toolCall.toolName,
253
+ normalizedPath,
254
+ paramsSummary: {
255
+ artifactId: sample.record.artifactId,
256
+ sourceSnapshotRef: sample.record.sourceSnapshotRef,
257
+ classification: sample.classification,
258
+ },
259
+ },
260
+ workspace: {
261
+ isRiskPath:
262
+ Boolean(matchedGateBlock) ||
263
+ (normalizedPath !== null && this._isRiskPath(normalizedPath)),
264
+ planStatus:
265
+ matchedGateBlock?.planStatus === 'READY' ||
266
+ matchedGateBlock?.planStatus === 'DRAFT' ||
267
+ matchedGateBlock?.planStatus === 'NONE'
268
+ ? matchedGateBlock.planStatus
269
+ : this._safePlanStatus(),
270
+ hasPlanFile: fs.existsSync(path.join(this.workspaceDir, 'PLAN.md')),
271
+ },
272
+ session: {
273
+ sessionId: sample.record.sessionId,
274
+ currentGfi: 0,
275
+ recentThinking: false,
276
+ },
277
+ evolution: {
278
+ epTier: 0,
279
+ },
280
+ derived: {
281
+ estimatedLineChanges: this._estimateLineChanges(toolCall),
282
+ bashRisk: this._inferBashRisk(toolCall),
283
+ },
284
+ };
285
+ }
286
+
287
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
288
+ private _selectToolCall(
289
+ snapshot: NocturnalSessionSnapshot,
290
+ classification: SampleClassification,
291
+ ): NocturnalToolCall | null {
292
+ const byNewest = [...snapshot.toolCalls].sort(
293
+ (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
294
+ );
295
+
296
+ if (classification === 'pain-negative') {
297
+ return (
298
+ byNewest.find((toolCall) => toolCall.outcome === 'blocked') ??
299
+ byNewest.find((toolCall) => toolCall.outcome === 'failure') ??
300
+ byNewest[0] ??
301
+ null
302
+ );
303
+ }
304
+
305
+ if (classification === 'success-positive' || classification === 'principle-anchor') {
306
+ return (
307
+ byNewest.find((toolCall) => toolCall.outcome === 'success') ??
308
+ byNewest.find((toolCall) => toolCall.outcome === 'failure') ??
309
+ byNewest[0] ??
310
+ null
311
+ );
312
+ }
313
+
314
+ return byNewest[0] ?? null;
315
+ }
316
+
317
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
318
+ private _matchGateBlock(
319
+ gateBlocks: NocturnalGateBlock[],
320
+ toolCall: NocturnalToolCall,
321
+ ): NocturnalGateBlock | null {
322
+ return (
323
+ [...gateBlocks]
324
+ .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())
325
+ .find((gateBlock) => gateBlock.toolName === toolCall.toolName) ?? null
326
+ );
327
+ }
328
+
329
+ private _isRiskPath(normalizedPath: string): boolean {
330
+ try {
331
+ const profilePath = path.join(this.workspaceDir, 'PROFILE.json');
332
+ const riskPaths =
333
+ fs.existsSync(profilePath)
334
+ ? (((JSON.parse(fs.readFileSync(profilePath, 'utf-8')) as { risk_paths?: unknown }).risk_paths as string[] | undefined) ?? [])
335
+ : [];
336
+ return isRisky(normalizedPath, riskPaths);
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ private _safePlanStatus(): 'NONE' | 'DRAFT' | 'READY' | 'UNKNOWN' {
343
+ try {
344
+ const status = getPlanStatus(this.workspaceDir);
345
+ if (status === 'READY') return 'READY';
346
+ if (status === 'DRAFT') return 'DRAFT';
347
+ if (status === '') return 'NONE';
348
+ return 'UNKNOWN';
349
+ } catch {
350
+ return 'UNKNOWN';
351
+ }
352
+ }
353
+
354
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
355
+ private _estimateLineChanges(toolCall: NocturnalToolCall): number {
356
+ if (toolCall.toolName === 'edit' || toolCall.toolName === 'write') {
357
+ return 20;
358
+ }
359
+ return 0;
360
+ }
361
+
362
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
363
+ private _inferBashRisk(toolCall: NocturnalToolCall): 'safe' | 'normal' | 'dangerous' | 'unknown' {
364
+ if (toolCall.toolName !== 'bash' && toolCall.toolName !== 'run_shell_command') {
365
+ return 'unknown';
366
+ }
367
+ const errorText = `${toolCall.errorType ?? ''} ${toolCall.errorMessage ?? ''}`;
368
+ if (/\brm\s+-rf\b|\bchmod\b|\bchown\b|>\s*\/dev\//.test(errorText)) {
369
+ return 'dangerous';
370
+ }
371
+ return toolCall.outcome === 'success' ? 'safe' : 'normal';
372
+ }
373
+
374
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
375
+ private _scoreEvaluation(
376
+ sample: ReplaySample,
377
+ result: RuleHostResult,
378
+ ): { passed: boolean; reason?: string; decision: string } {
379
+ switch (sample.classification) {
380
+ case 'pain-negative':
381
+ return {
382
+ passed: result.decision === 'block' || result.decision === 'requireApproval',
383
+ reason:
384
+ result.decision === 'block' || result.decision === 'requireApproval'
385
+ ? undefined
386
+ : `Expected block/requireApproval but received ${result.decision}.`,
387
+ decision: result.decision,
388
+ };
389
+ case 'success-positive':
390
+ return {
391
+ passed: result.decision === 'allow' || !result.matched,
392
+ reason:
393
+ result.decision === 'allow' || !result.matched
394
+ ? undefined
395
+ : `Expected allow/no-match but received ${result.decision}.`,
396
+ decision: result.decision,
397
+ };
398
+ case 'principle-anchor':
399
+ return {
400
+ passed: result.decision !== 'block',
401
+ reason:
402
+ result.decision !== 'block'
403
+ ? undefined
404
+ : 'Principle-anchor sample should not regress to a hard block.',
405
+ decision: result.decision,
406
+ };
407
+ default:
408
+ return {
409
+ passed: false,
410
+ reason: 'Unknown replay classification.',
411
+ decision: result.decision,
412
+ };
413
+ }
414
+ }
415
+
416
+ private _buildReport(
417
+ implementationId: string,
418
+ results: ReplayResult[]
419
+ ): ReplayReport {
420
+ const painNegative = results.filter((result) => result.classification === 'pain-negative');
421
+ const successPositive = results.filter((result) => result.classification === 'success-positive');
422
+ const principleAnchor = results.filter((result) => result.classification === 'principle-anchor');
423
+
424
+ const toSummary = (details: ReplayResult[]): ClassificationSummary => ({
425
+ total: details.length,
426
+ passed: details.filter((result) => result.passed).length,
427
+ failed: details.filter((result) => !result.passed).length,
428
+ details,
429
+ });
430
+
431
+ const painSummary = toSummary(painNegative);
432
+ const successSummary = toSummary(successPositive);
433
+ const anchorSummary = toSummary(principleAnchor);
434
+ const blockers: string[] = [];
435
+
436
+ for (const leak of painSummary.details.filter((result) => !result.passed)) {
437
+ blockers.push(
438
+ `PAIN-NEGATIVE LEAK: Sample ${leak.sampleFingerprint} was not blocked. ${leak.reason ?? ''}`
439
+ );
440
+ }
441
+
442
+ for (const violation of anchorSummary.details.filter((result) => !result.passed)) {
443
+ blockers.push(
444
+ `PRINCIPLE-ANCHOR VIOLATION: Sample ${violation.sampleFingerprint} did not adhere. ${violation.reason ?? ''}`
445
+ );
446
+ }
447
+
448
+ for (const falsePositive of successSummary.details.filter((result) => !result.passed)) {
449
+ blockers.push(
450
+ `FALSE POSITIVE: Sample ${falsePositive.sampleFingerprint} was incorrectly blocked. ${falsePositive.reason ?? ''}`
451
+ );
452
+ }
453
+
454
+ return {
455
+ overallDecision: this._determineDecision(painSummary, successSummary, anchorSummary),
456
+ replayResults: {
457
+ painNegative: painSummary,
458
+ successPositive: successSummary,
459
+ principleAnchor: anchorSummary,
460
+ },
461
+ blockers,
462
+ generatedAt: new Date().toISOString(),
463
+ implementationId,
464
+ sampleFingerprints: results.map((result) => result.sampleFingerprint),
465
+ };
466
+ }
467
+
468
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
469
+ private _determineDecision(
470
+ pain: ClassificationSummary,
471
+ success: ClassificationSummary,
472
+ anchor: ClassificationSummary
473
+ ): 'pass' | 'fail' | 'needs-review' {
474
+ if (pain.failed > 0) return 'fail';
475
+ if (anchor.failed > 0) return 'fail';
476
+ if (success.failed > 0) return 'needs-review';
477
+ return 'pass';
478
+ }
479
+
480
+ private _persistReport(report: ReplayReport): void {
481
+ const reportDir = path.join(
482
+ getImplementationAssetRoot(this.stateDir, report.implementationId),
483
+ 'replays'
484
+ );
485
+
486
+ if (!fs.existsSync(reportDir)) {
487
+ fs.mkdirSync(reportDir, { recursive: true });
488
+ }
489
+
490
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
491
+ const reportPath = path.join(reportDir, `${timestamp}.json`);
492
+
493
+ withLock(reportPath, () => {
494
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
495
+ });
496
+ }
497
+
498
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Reason: Private helper doesn't use instance state
499
+ private _deriveExpectedOutcome(
500
+ record: NocturnalDatasetRecord,
501
+ ): ReplaySample['expectedOutcome'] {
502
+ switch (record.classification) {
503
+ case 'pain-negative':
504
+ return { shouldBlock: true };
505
+ case 'success-positive':
506
+ return { shouldPass: true };
507
+ case 'principle-anchor':
508
+ return { expectedPrinciple: record.principleId };
509
+ default:
510
+ return {};
511
+ }
512
+ }
513
+ }
514
+
515
+ export function formatReplayReport(report: ReplayReport): string {
516
+ const decisionEmoji =
517
+ report.overallDecision === 'pass'
518
+ ? 'PASS'
519
+ : report.overallDecision === 'fail'
520
+ ? 'FAIL'
521
+ : 'NEEDS-REVIEW';
522
+
523
+ let output = '';
524
+ output += '\nReplay Evaluation Report\n';
525
+ output += `${'='.repeat(50)}\n`;
526
+ output += `Implementation: ${report.implementationId}\n`;
527
+ output += `Generated At: ${report.generatedAt}\n`;
528
+ output += `Overall Decision: [${decisionEmoji}]\n\n`;
529
+
530
+ const formatSection = (
531
+ label: string,
532
+ summary: ClassificationSummary
533
+ ) => {
534
+ const rate = summary.total > 0
535
+ ? ((summary.passed / summary.total) * 100).toFixed(1)
536
+ : 'N/A';
537
+ let section = ` ${label}:\n`;
538
+ section += ` Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed}\n`;
539
+ section += ` Pass Rate: ${rate}%\n`;
540
+ if (summary.failed > 0) {
541
+ section += ' Failures:\n';
542
+ for (const detail of summary.details.filter((item) => !item.passed)) {
543
+ section += ` - ${detail.sampleFingerprint}: ${detail.reason ?? detail.decision}\n`;
544
+ }
545
+ }
546
+ return section;
547
+ };
548
+
549
+ output += formatSection('Pain-Negative Samples', report.replayResults.painNegative);
550
+ output += formatSection('Success-Positive Samples', report.replayResults.successPositive);
551
+ output += formatSection('Principle-Anchor Samples', report.replayResults.principleAnchor);
552
+
553
+ if (report.blockers.length > 0) {
554
+ output += '\nBlockers:\n';
555
+ for (const blocker of report.blockers) {
556
+ output += ` - ${blocker}\n`;
557
+ }
558
+ }
559
+
560
+ output += `${'='.repeat(50)}\n`;
561
+ return output;
562
+ }
@@ -1,3 +1,4 @@
1
+ /* global NodeJS */
1
2
  import * as fs from 'fs';
2
3
  import { isRisky } from '../utils/io.js';
3
4
 
@@ -5,24 +6,24 @@ export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
5
6
 
6
7
  export interface FileModification {
7
8
  toolName: string;
8
- params: any;
9
+ params: Record<string, unknown>;
9
10
  }
10
11
 
11
12
  export function estimateLineChanges(modification: FileModification): number {
12
13
  const { toolName, params } = modification;
13
14
 
14
15
  if (toolName === 'write_file' || toolName === 'write') {
15
- const content = params.content || '';
16
+ const content = (params.content as string) || '';
16
17
  return content.split('\n').length;
17
18
  }
18
19
 
19
20
  if (toolName === 'replace' || toolName === 'edit') {
20
- const newContent = params.new_string || params.newText || '';
21
+ const newContent = (params.new_string as string) || (params.newText as string) || '';
21
22
  return newContent.split('\n').length;
22
23
  }
23
24
 
24
25
  if (toolName === 'apply_patch' || toolName === 'patch') {
25
- const patch = params.patch || '';
26
+ const patch = (params.patch as string) || '';
26
27
  // Rough estimate for patch files
27
28
  return patch.split('\n').filter((l: string) => l.startsWith('+') || l.startsWith('-')).length;
28
29
  }
@@ -92,6 +93,7 @@ export function getTargetFileLineCount(absoluteFilePath: string): number | null
92
93
  * @param maxLines - Optional upper bound to prevent misconfiguration
93
94
  * @returns Maximum allowed lines (at least minLines, at most maxLines if provided)
94
95
  */
96
+ // eslint-disable-next-line @typescript-eslint/max-params -- Reason: percentage threshold calculation requires all 4 params - refactoring would break API
95
97
  export function calculatePercentageThreshold(
96
98
  targetLineCount: number,
97
99
  percentage: number,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Rule Host Helpers — Minimal whitelisted helper surface for hosted implementations
3
+ *
4
+ * PURPOSE: Provide a constrained set of pure functions that hosted implementations
5
+ * can call. All values are pre-computed from the frozen RuleHostInput snapshot.
6
+ *
7
+ * SECURITY:
8
+ * - Helpers are a frozen object — cannot be modified by implementations
9
+ * - No filesystem, process, require, dynamic import, eval, Function, or network access
10
+ * - All functions are pure — no side effects, no external state access
11
+ */
12
+
13
+ import type { RuleHostInput } from './rule-host-types.js';
14
+
15
+ export interface RuleHostHelpers {
16
+ isRiskPath(): boolean;
17
+ getToolName(): string;
18
+ getEstimatedLineChanges(): number;
19
+ getBashRisk(): 'safe' | 'normal' | 'dangerous' | 'unknown';
20
+ hasPlanFile(): boolean;
21
+ getPlanStatus(): 'NONE' | 'DRAFT' | 'READY' | 'UNKNOWN';
22
+ getCurrentEpiTier(): number;
23
+ }
24
+
25
+ /**
26
+ * Create a frozen helper object from the pre-computed input snapshot.
27
+ * Implementations receive this via the vm context — they cannot modify it.
28
+ */
29
+ export function createRuleHostHelpers(input: RuleHostInput): RuleHostHelpers {
30
+ return Object.freeze({
31
+ isRiskPath: () => input.workspace.isRiskPath,
32
+ getToolName: () => input.action.toolName,
33
+ getEstimatedLineChanges: () => input.derived.estimatedLineChanges,
34
+ getBashRisk: () => input.derived.bashRisk,
35
+ hasPlanFile: () => input.workspace.hasPlanFile,
36
+ getPlanStatus: () => input.workspace.planStatus,
37
+ getCurrentEpiTier: () => input.evolution.epTier,
38
+ });
39
+ }