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
@@ -200,26 +200,17 @@ const READ_TOOLS: Set<string> = new Set([
200
200
  * Normalizes a file path to POSIX forward-slash format for consistent matching.
201
201
  * Handles Windows backslash paths on any platform.
202
202
  */
203
- function normalizePath(filePath: string): string {
203
+ function normalizePathPosix(filePath: string): string {
204
204
  return filePath.replace(/\\/g, '/');
205
205
  }
206
206
 
207
- /**
208
- * Returns true if the file path matches any of the given patterns when normalized.
209
- */
210
- function pathMatches(filePath: string | undefined, patterns: RegExp[]): boolean {
211
- if (!filePath) return false;
212
- const normalized = normalizePath(filePath);
213
- return patterns.some((p) => p.test(normalized));
214
- }
215
-
216
207
  // ---------------------------------------------------------------------------
217
208
  // Opportunity Detection
218
209
  // ---------------------------------------------------------------------------
219
210
 
220
211
  /**
221
212
  * Detects whether a given session presents an APPLICABLE OPPORTUNITY
222
- * for a specific T-xx principle.
213
+ * for a specific principle.
223
214
  *
224
215
  * An opportunity exists when the session context falls within the
225
216
  * principle's applicability scope — regardless of whether the agent
@@ -227,8 +218,28 @@ function pathMatches(filePath: string | undefined, patterns: RegExp[]): boolean
227
218
  *
228
219
  * IMPORTANT: This does NOT assess compliance. It only answers:
229
220
  * "Could the principle have applied here?"
221
+ *
222
+ * #216: For P_* principles (not T-xx), uses generic detection based on
223
+ * pain events and tool calls — any session with a pain signal is considered
224
+ * an opportunity for a pain-derived principle.
230
225
  */
231
226
  export function detectOpportunity(principleId: string, session: SessionEvents): OpportunityMatch {
227
+ // #216: P_* principles (pain-derived) — generic opportunity detection
228
+ if (principleId.startsWith('P_')) {
229
+ // Any session with pain signals, tool failures, or gate blocks is an opportunity
230
+ // for a pain-derived principle. This is conservative: better to over-count
231
+ // opportunities than to miss real violations.
232
+ const hasPainSignal = session.painSignals.length > 0;
233
+ const hasToolFailure = session.toolCalls.some((tc) => tc.outcome === 'failure');
234
+ const hasGateBlock = session.gateBlocks.length > 0;
235
+ if (hasPainSignal || hasToolFailure || hasGateBlock) {
236
+ return { applicable: true, reason: `P_* principle — session has ${hasPainSignal ? 'pain signal' : hasToolFailure ? 'tool failure' : 'gate block'}` };
237
+ }
238
+ return { applicable: false, reason: `P_* principle — no pain/tool-failure/gate-block in session` };
239
+ }
240
+
241
+ // T-xx principles — specific deterministic detection
242
+ /* eslint-disable @typescript-eslint/no-use-before-define -- Reason: Mutual recursion between helper functions - reordering would break logical grouping */
232
243
  switch (principleId) {
233
244
  case 'T-01':
234
245
  return detectT01Opportunity(session);
@@ -276,7 +287,7 @@ function detectT01Opportunity(session: SessionEvents): OpportunityMatch {
276
287
  function detectT02Opportunity(session: SessionEvents): OpportunityMatch {
277
288
  const hasConstraintInteraction = session.toolCalls.some((call) => {
278
289
  if (!call.filePath) return false;
279
- const normalized = normalizePath(call.filePath);
290
+ const normalized = normalizePathPosix(call.filePath);
280
291
  return (
281
292
  /\.(ts|tsx|js|jsx)$/.test(normalized) || // type-aware files
282
293
  /\b(test|spec|contract|schema|interface|type)\b/i.test(normalized)
@@ -364,6 +375,7 @@ function detectT05Opportunity(session: SessionEvents): OpportunityMatch {
364
375
  if (RISKY_TOOLS.has(call.toolName)) return true;
365
376
  // Check bash for dangerous patterns
366
377
  if (call.toolName === 'bash' && call.errorMessage) {
378
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: call.errorMessage guard above ensures truthiness
367
379
  return DANGEROUS_BASH_PATTERNS.some((p) => p.test(call.errorMessage!));
368
380
  }
369
381
  return false;
@@ -411,7 +423,8 @@ function detectT06Opportunity(session: SessionEvents): OpportunityMatch {
411
423
  function detectT07Opportunity(session: SessionEvents): OpportunityMatch {
412
424
  const filePaths = session.toolCalls
413
425
  .filter((call) => call.filePath !== undefined)
414
- .map((call) => normalizePath(call.filePath!));
426
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: filter ensures filePath is defined
427
+ .map((call) => normalizePathPosix(call.filePath!));
415
428
  const uniqueFiles = new Set(filePaths);
416
429
  if (uniqueFiles.size >= 3) {
417
430
  return {
@@ -456,7 +469,8 @@ function detectT09Opportunity(session: SessionEvents): OpportunityMatch {
456
469
  const uniqueFiles = new Set(
457
470
  session.toolCalls
458
471
  .filter((call) => call.filePath !== undefined)
459
- .map((call) => normalizePath(call.filePath!))
472
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: filter ensures filePath is defined
473
+ .map((call) => normalizePathPosix(call.filePath!))
460
474
  );
461
475
  const hasComplexity = toolCallCount >= 5 || uniqueFiles.size >= 3;
462
476
 
@@ -493,8 +507,37 @@ function detectT09Opportunity(session: SessionEvents): OpportunityMatch {
493
507
  * opportunity was applicable.
494
508
  *
495
509
  * Returns a ViolationMatch with violated=true if violation signals are present.
510
+ *
511
+ * #216: For P_* principles (pain-derived), violation is detected when the session
512
+ * has pain signals, tool failures, or gate blocks that match the principle's
513
+ * trigger pattern. Since P_* principles don't have T-xx specific detectors,
514
+ * we use the presence of negative signals as violation evidence.
496
515
  */
497
516
  export function detectViolation(principleId: string, session: SessionEvents): ViolationMatch {
517
+ // #216: P_* principles (pain-derived) — generic violation detection
518
+ if (principleId.startsWith('P_')) {
519
+ // For pain-derived principles, a violation is indicated when the session
520
+ // contains pain signals, tool failures, or gate blocks — these are the
521
+ // same signals that triggered principle creation in the first place.
522
+ // A principle was violated if the bad outcome recurred after it was created.
523
+ const painSignals = session.painSignals.filter((p) => p.score >= 50);
524
+ const toolFailures = session.toolCalls.filter((tc) => tc.outcome === 'failure');
525
+ const {gateBlocks} = session;
526
+
527
+ if (painSignals.length > 0) {
528
+ return { violated: true, reason: `P_* principle — ${painSignals.length} pain signal(s) detected (max score: ${Math.max(...painSignals.map(p => p.score))})` };
529
+ }
530
+ if (toolFailures.length > 0) {
531
+ return { violated: true, reason: `P_* principle — ${toolFailures.length} tool failure(s) detected` };
532
+ }
533
+ if (gateBlocks.length > 0) {
534
+ return { violated: true, reason: `P_* principle — ${gateBlocks.length} gate block(s) detected` };
535
+ }
536
+ return { violated: false, reason: `P_* principle — no violation signals in session` };
537
+ }
538
+
539
+ // T-xx principles — specific deterministic detection
540
+ /* eslint-disable @typescript-eslint/no-use-before-define -- Reason: Mutual recursion between helper functions - reordering would break logical grouping */
498
541
  switch (principleId) {
499
542
  case 'T-01':
500
543
  return detectT01Violation(session);
@@ -529,7 +572,8 @@ function detectT01Violation(session: SessionEvents): ViolationMatch {
529
572
  const readFiles = new Set(
530
573
  session.toolCalls
531
574
  .filter((call) => READ_TOOLS.has(call.toolName) && call.filePath !== undefined)
532
- .map((call) => normalizePath(call.filePath!))
575
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: filter ensures filePath is defined
576
+ .map((call) => normalizePathPosix(call.filePath!))
533
577
  );
534
578
 
535
579
  // Find edits to files that were NOT read first
@@ -537,14 +581,14 @@ function detectT01Violation(session: SessionEvents): ViolationMatch {
537
581
  (call) =>
538
582
  EDIT_TOOLS.has(call.toolName) &&
539
583
  call.filePath !== undefined &&
540
- !readFiles.has(normalizePath(call.filePath!))
584
+ !readFiles.has(normalizePathPosix(call.filePath))
541
585
  );
542
586
 
543
587
  // If there were edits to unread files AND pain/failure followed → T-01 likely violated
544
588
  if (unreadEdits.length > 0) {
545
589
  const painOnUnreadEdit = session.painSignals.some(
546
590
  (p) =>
547
- unreadEdits.some((e) => e.filePath !== undefined && p.source.includes(e.filePath!)) ||
591
+ unreadEdits.some((e) => e.filePath !== undefined && p.source.includes(e.filePath)) ||
548
592
  /structure|architecture|dependency|context|before.*edit|survey/i.test(p.reason ?? '')
549
593
  );
550
594
 
@@ -722,7 +766,8 @@ function detectT07Violation(session: SessionEvents): ViolationMatch {
722
766
  const modifiedFiles = new Set(
723
767
  session.toolCalls
724
768
  .filter((call) => EDIT_TOOLS.has(call.toolName) && call.filePath !== undefined)
725
- .map((call) => normalizePath(call.filePath!))
769
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: filter ensures filePath is defined
770
+ .map((call) => normalizePathPosix(call.filePath!))
726
771
  );
727
772
 
728
773
  const failures = session.toolCalls.filter((call) => call.outcome === 'failure');
@@ -778,7 +823,8 @@ function detectT09Violation(session: SessionEvents): ViolationMatch {
778
823
  const uniqueFiles = new Set(
779
824
  session.toolCalls
780
825
  .filter((call) => call.filePath !== undefined)
781
- .map((call) => normalizePath(call.filePath!))
826
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: filter ensures filePath is defined
827
+ .map((call) => normalizePathPosix(call.filePath!))
782
828
  );
783
829
 
784
830
  // Only applies if the session was complex
@@ -940,6 +986,7 @@ function computeViolationTrend(
940
986
  /**
941
987
  * Builds a human-readable explanation for the compliance result.
942
988
  */
989
+ // eslint-disable-next-line @typescript-eslint/max-params -- Reason: explanation builder requires all context parameters - refactoring would break API
943
990
  function buildExplanation(
944
991
  principleId: string,
945
992
  applicableOpportunityCount: number,
@@ -1016,6 +1063,7 @@ export function groupEventsIntoSessions(events: RawEventEntry[]): Map<string, Se
1016
1063
  });
1017
1064
  }
1018
1065
 
1066
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: set() above guarantees get() returns non-null
1019
1067
  const session = sessionMap.get(sessionId)!;
1020
1068
 
1021
1069
  switch (event.type) {
@@ -46,6 +46,18 @@ export type NocturnalReviewStatus =
46
46
  | 'rejected'
47
47
  | 'superseded';
48
48
 
49
+ /**
50
+ * Sample classification for replay evaluation.
51
+ * Used by the ReplayEngine to select appropriate test samples.
52
+ * - 'pain-negative': samples that triggered pain signals or were blocked by gates
53
+ * - 'success-positive': samples that were successful interactions
54
+ * - 'principle-anchor': samples that embody core principle behavior
55
+ *
56
+ * Artifact kind is intentionally tracked outside this type so replay
57
+ * classification remains behavioral-only.
58
+ */
59
+ export type SampleClassification = 'pain-negative' | 'success-positive' | 'principle-anchor';
60
+
49
61
  /**
50
62
  * A nocturnal dataset record — the immutable lineage entry for one sample.
51
63
  *
@@ -105,6 +117,13 @@ export interface NocturnalDatasetRecord {
105
117
  * Absolute path to the artifact file.
106
118
  */
107
119
  artifactPath: string;
120
+
121
+ /**
122
+ * Sample classification for replay evaluation.
123
+ * Used by ReplayEngine to select samples by category.
124
+ * Null means not yet classified for replay.
125
+ */
126
+ classification: SampleClassification | null;
108
127
  }
109
128
 
110
129
  /**
@@ -240,7 +259,8 @@ function writeRegistry(workspaceDir: string, records: NocturnalDatasetRecord[]):
240
259
  * Execute a read-modify-write on the registry under an exclusive lock.
241
260
  * This prevents concurrent writers from racing on the same file.
242
261
  */
243
- function withRegistryLock<T>(workspaceDir: string, fn: (records: NocturnalDatasetRecord[]) => T): T {
262
+ /* eslint-disable no-unused-vars -- Reason: _records is a type signature parameter, unused by design in function type definition */
263
+ function withRegistryLock<T>(workspaceDir: string, fn: (_records: NocturnalDatasetRecord[]) => T): T {
244
264
  const registryPath = getRegistryPath(workspaceDir);
245
265
  return withLock(registryPath, () => {
246
266
  const records = readRegistry(workspaceDir);
@@ -260,13 +280,16 @@ function withRegistryLock<T>(workspaceDir: string, fn: (records: NocturnalDatase
260
280
  * @param artifact - The approved NocturnalArtifact
261
281
  * @param artifactPath - Absolute path where the artifact file is stored
262
282
  * @param targetModelFamily - Model family binding (required for export-ready)
283
+ * @param classification - Optional replay classification
263
284
  * @returns RegisterSampleResult
264
285
  */
286
+ // eslint-disable-next-line @typescript-eslint/max-params -- Reason: sample registration requires workspace + artifact + path + family - refactoring would break API
265
287
  export function registerSample(
266
288
  workspaceDir: string,
267
289
  artifact: NocturnalArtifact,
268
290
  artifactPath: string,
269
- targetModelFamily: string | null = null
291
+ targetModelFamily: string | null = null,
292
+ classification: SampleClassification | null = null
270
293
  ): RegisterSampleResult {
271
294
  const fingerprint = generateFingerprintFromArtifact(artifact);
272
295
  const now = new Date().toISOString();
@@ -293,6 +316,7 @@ export function registerSample(
293
316
  createdAt: now,
294
317
  updatedAt: now,
295
318
  artifactPath: path.normalize(artifactPath),
319
+ classification,
296
320
  };
297
321
 
298
322
  records.push(record);
@@ -401,6 +425,7 @@ const VALID_TRANSITIONS: Record<NocturnalReviewStatus, NocturnalReviewStatus[]>
401
425
  * @returns Updated record, or null if not found
402
426
  * @throws Error if transition is invalid
403
427
  */
428
+ // eslint-disable-next-line @typescript-eslint/max-params -- Reason: status update requires workspace + fingerprint + status - refactoring would break API
404
429
  export function updateReviewStatus(
405
430
  workspaceDir: string,
406
431
  sampleFingerprint: string,
@@ -666,3 +691,71 @@ export function migrateSampleArtifacts(
666
691
 
667
692
  return newCount;
668
693
  }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Replay Classification Support
697
+ // ---------------------------------------------------------------------------
698
+
699
+ /**
700
+ * List samples filtered by replay classification.
701
+ *
702
+ * @param workspaceDir - Workspace directory
703
+ * @param classification - Classification to filter by
704
+ * @returns Records matching the classification, sorted by createdAt descending
705
+ */
706
+ export function listSamplesByClassification(
707
+ workspaceDir: string,
708
+ classification: SampleClassification
709
+ ): NocturnalDatasetRecord[] {
710
+ const records = readRegistry(workspaceDir);
711
+ return records
712
+ .filter((r) => r.classification === classification)
713
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
714
+ }
715
+
716
+ /**
717
+ * Load and parse the artifact content for a given dataset record.
718
+ *
719
+ * @param workspaceDir - Workspace directory
720
+ * @param record - The dataset record whose artifact to load
721
+ * @returns The parsed artifact JSON content
722
+ * @throws Error if artifact file is missing or unreadable
723
+ */
724
+ export function loadSampleContent(
725
+ workspaceDir: string,
726
+ record: NocturnalDatasetRecord
727
+ ): unknown {
728
+ if (!fs.existsSync(record.artifactPath)) {
729
+ throw new Error(`Artifact file missing for sample ${record.sampleFingerprint}: ${record.artifactPath}`);
730
+ }
731
+ const content = fs.readFileSync(record.artifactPath, 'utf-8');
732
+ return JSON.parse(content);
733
+ }
734
+
735
+ /**
736
+ * Update the classification of a dataset record.
737
+ *
738
+ * @param workspaceDir - Workspace directory
739
+ * @param sampleFingerprint - The fingerprint of the record to update
740
+ * @param classification - New classification value
741
+ * @returns Updated record
742
+ */
743
+ export function updateSampleClassification(
744
+ workspaceDir: string,
745
+ sampleFingerprint: string,
746
+ classification: SampleClassification | null
747
+ ): NocturnalDatasetRecord {
748
+ return withRegistryLock(workspaceDir, (records) => {
749
+ const idx = records.findIndex((r) => r.sampleFingerprint === sampleFingerprint);
750
+ if (idx === -1) {
751
+ throw new Error(`Dataset record not found: ${sampleFingerprint}`);
752
+ }
753
+ records[idx] = {
754
+ ...records[idx],
755
+ classification,
756
+ updatedAt: new Date().toISOString(),
757
+ };
758
+ writeRegistry(workspaceDir, records);
759
+ return records[idx];
760
+ });
761
+ }
@@ -203,7 +203,7 @@ function parseBoundedAction(text: string): BoundedAction | null {
203
203
 
204
204
  // Pattern: "Read/Check/Verify X" or "X. Read/Check/Verify Y"
205
205
  const boundedPattern = /^([A-Za-z]+)\s+(?:the\s+)?([^\s.,]+(?:\s+[^\s.,]+){0,5})/i;
206
- const match = trimmed.match(boundedPattern);
206
+ const match = boundedPattern.exec(trimmed);
207
207
 
208
208
  if (match) {
209
209
  const verb = match[1].toLowerCase();
@@ -216,7 +216,7 @@ function parseBoundedAction(text: string): BoundedAction | null {
216
216
 
217
217
  // Pattern: "[Verb] the [target]" — e.g., "read the file"
218
218
  const thePattern = /^(read|check|verify|edit|write|delete|search|grep|look|examine|inspect|review)\s+(?:the\s+)?(.+)/i;
219
- const theMatch = lower.match(thePattern);
219
+ const theMatch = thePattern.exec(lower);
220
220
  if (theMatch) {
221
221
  return { verb: theMatch[1], target: theMatch[2].trim(), fullText: trimmed };
222
222
  }
@@ -294,7 +294,6 @@ export function validateExecutability(artifact: {
294
294
 
295
295
  // Check betterDecision is executable
296
296
  const boundedAction = parseBoundedAction(artifact.betterDecision);
297
- const lowerBetter = artifact.betterDecision.toLowerCase();
298
297
 
299
298
  // Check 1: Not a hollow pattern
300
299
  if (containsHollowPattern(artifact.betterDecision)) {
@@ -96,12 +96,12 @@ export interface ORPOExportManifest {
96
96
  datasetFingerprint: string;
97
97
  exportPath: string;
98
98
  manifestPath: string;
99
- samples: Array<{
99
+ samples: {
100
100
  sampleFingerprint: string;
101
101
  artifactId: string;
102
102
  sessionId: string;
103
103
  principleId: string;
104
- }>;
104
+ }[];
105
105
  }
106
106
 
107
107
  /**
@@ -136,6 +136,7 @@ function computeDatasetFingerprint(sampleFingerprints: string[]): string {
136
136
  * Serialize a single dataset record + artifact to ORPO JSONL line.
137
137
  * Caller guarantees record.targetModelFamily is non-null.
138
138
  */
139
+ // eslint-disable-next-line @typescript-eslint/max-params -- Reason: serialization requires record + artifact + export info - refactoring would break internal API
139
140
  function serializeORPOSample(
140
141
  record: NocturnalDatasetRecord,
141
142
  artifact: ReturnType<typeof readDatasetArtifact>,
@@ -182,7 +183,7 @@ function serializeORPOSample(
182
183
  export function exportORPOSamples(
183
184
  workspaceDir: string,
184
185
  targetModelFamily?: string | null,
185
- _options: Record<string, never> = {}
186
+ _options: Record<string, never> = {} // eslint-disable-line @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: options parameter intentionally unused
186
187
  ): ExportResult {
187
188
  const exportId = crypto.randomUUID();
188
189
  const now = new Date().toISOString();
@@ -194,6 +195,7 @@ export function exportORPOSamples(
194
195
  reviewStatus: 'approved_for_training',
195
196
  });
196
197
 
198
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in both if/else branches
197
199
  let eligibleRecords: typeof allApprovedRecords;
198
200
 
199
201
  if (targetModelFamily !== undefined && targetModelFamily !== null) {
@@ -242,6 +244,7 @@ export function exportORPOSamples(
242
244
  }
243
245
 
244
246
  // Read artifact (throws on error — distinguishes read failure from missing artifact)
247
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in try, catch continues to next iteration
245
248
  let artifact;
246
249
  try {
247
250
  artifact = readDatasetArtifact(workspaceDir, record.sampleFingerprint);
@@ -0,0 +1,245 @@
1
+ import { createRuleHostHelpers, type RuleHostHelpers } from './rule-host-helpers.js';
2
+ import { loadRuleImplementationModule } from './rule-implementation-runtime.js';
3
+ import type {
4
+ RuleHostDecision,
5
+ RuleHostInput,
6
+ RuleHostMeta,
7
+ RuleHostResult,
8
+ } from './rule-host-types.js';
9
+
10
+ export interface RuleImplementationValidationFailure {
11
+ code:
12
+ | 'forbidden-api'
13
+ | 'compile-error'
14
+ | 'missing-meta'
15
+ | 'invalid-meta'
16
+ | 'missing-evaluate'
17
+ | 'invalid-result';
18
+ message: string;
19
+ detail?: string;
20
+ }
21
+
22
+ export interface RuleImplementationValidationResult {
23
+ passed: boolean;
24
+ failures: RuleImplementationValidationFailure[];
25
+ helperUsage: string[];
26
+ normalizedSource?: string;
27
+ meta?: RuleHostMeta;
28
+ }
29
+
30
+ const FORBIDDEN_PATTERNS: { pattern: RegExp; label: string }[] = [
31
+ { pattern: /\beval\s*\(/, label: 'eval' },
32
+ { pattern: /\bFunction\s*\(/, label: 'Function' },
33
+ { pattern: /\bimport\s*\(/, label: 'dynamic import' },
34
+ { pattern: /\brequire\s*\(/, label: 'require' },
35
+ { pattern: /\bfetch\s*\(/, label: 'fetch' },
36
+ { pattern: /\bXMLHttpRequest\b/, label: 'XMLHttpRequest' },
37
+ { pattern: /\bchild_process\b/, label: 'child_process' },
38
+ { pattern: /\bprocess\b/, label: 'process' },
39
+ { pattern: /\bfs\b/, label: 'fs' },
40
+ { pattern: /\bhttp\b/, label: 'http' },
41
+ { pattern: /\bhttps\b/, label: 'https' },
42
+ { pattern: /\bnet\b/, label: 'net' },
43
+ ];
44
+
45
+ const HELPER_NAMES: (keyof RuleHostHelpers)[] = [
46
+ 'isRiskPath',
47
+ 'getToolName',
48
+ 'getEstimatedLineChanges',
49
+ 'getBashRisk',
50
+ 'hasPlanFile',
51
+ 'getPlanStatus',
52
+ 'getCurrentEpiTier',
53
+ ];
54
+
55
+ function createValidationInput(): RuleHostInput {
56
+ return {
57
+ action: {
58
+ toolName: 'write',
59
+ normalizedPath: 'src/risk.ts',
60
+ paramsSummary: {},
61
+ },
62
+ workspace: {
63
+ isRiskPath: true,
64
+ planStatus: 'DRAFT',
65
+ hasPlanFile: true,
66
+ },
67
+ session: {
68
+ sessionId: 'validator-session',
69
+ currentGfi: 3,
70
+ recentThinking: false,
71
+ },
72
+ evolution: {
73
+ epTier: 2,
74
+ },
75
+ derived: {
76
+ estimatedLineChanges: 42,
77
+ bashRisk: 'normal',
78
+ },
79
+ };
80
+ }
81
+
82
+ function extractHelperUsage(sourceCode: string): string[] {
83
+ return HELPER_NAMES.filter((helperName) =>
84
+ new RegExp(`\\bhelpers\\.${helperName}\\b|\\b${helperName}\\s*\\(`).test(sourceCode)
85
+ );
86
+ }
87
+
88
+ function validateMeta(meta: unknown): RuleImplementationValidationFailure[] {
89
+ if (!meta || typeof meta !== 'object') {
90
+ return [
91
+ {
92
+ code: 'missing-meta',
93
+ message: 'Candidate must export a meta object.',
94
+ },
95
+ ];
96
+ }
97
+
98
+ const candidate = meta as Partial<RuleHostMeta>;
99
+ const failures: RuleImplementationValidationFailure[] = [];
100
+
101
+ if (typeof candidate.name !== 'string' || candidate.name.trim().length === 0) {
102
+ failures.push({
103
+ code: 'invalid-meta',
104
+ message: 'meta.name must be a non-empty string.',
105
+ detail: 'name',
106
+ });
107
+ }
108
+ if (typeof candidate.version !== 'string' || candidate.version.trim().length === 0) {
109
+ failures.push({
110
+ code: 'invalid-meta',
111
+ message: 'meta.version must be a non-empty string.',
112
+ detail: 'version',
113
+ });
114
+ }
115
+ if (typeof candidate.ruleId !== 'string' || candidate.ruleId.trim().length === 0) {
116
+ failures.push({
117
+ code: 'invalid-meta',
118
+ message: 'meta.ruleId must be a non-empty string.',
119
+ detail: 'ruleId',
120
+ });
121
+ }
122
+ if (
123
+ typeof candidate.coversCondition !== 'string' ||
124
+ candidate.coversCondition.trim().length === 0
125
+ ) {
126
+ failures.push({
127
+ code: 'invalid-meta',
128
+ message: 'meta.coversCondition must be a non-empty string.',
129
+ detail: 'coversCondition',
130
+ });
131
+ }
132
+
133
+ return failures;
134
+ }
135
+
136
+ function validateResult(result: unknown): RuleImplementationValidationFailure[] {
137
+ if (!result || typeof result !== 'object') {
138
+ return [
139
+ {
140
+ code: 'invalid-result',
141
+ message: 'evaluate must return a RuleHostResult object.',
142
+ },
143
+ ];
144
+ }
145
+
146
+ const candidate = result as Partial<RuleHostResult>;
147
+ const failures: RuleImplementationValidationFailure[] = [];
148
+ const allowedDecisions: RuleHostDecision[] = ['allow', 'block', 'requireApproval'];
149
+
150
+ if (!allowedDecisions.includes(candidate.decision as RuleHostDecision)) {
151
+ failures.push({
152
+ code: 'invalid-result',
153
+ message: 'evaluate.decision must be allow, block, or requireApproval.',
154
+ detail: 'decision',
155
+ });
156
+ }
157
+ if (typeof candidate.matched !== 'boolean') {
158
+ failures.push({
159
+ code: 'invalid-result',
160
+ message: 'evaluate.matched must be a boolean.',
161
+ detail: 'matched',
162
+ });
163
+ }
164
+ if (typeof candidate.reason !== 'string') {
165
+ failures.push({
166
+ code: 'invalid-result',
167
+ message: 'evaluate.reason must be a string.',
168
+ detail: 'reason',
169
+ });
170
+ }
171
+
172
+ return failures;
173
+ }
174
+
175
+ export function validateRuleImplementationCandidate(
176
+ sourceCode: string
177
+ ): RuleImplementationValidationResult {
178
+ const helperUsage = extractHelperUsage(sourceCode);
179
+ const failures: RuleImplementationValidationFailure[] = [];
180
+
181
+ for (const forbidden of FORBIDDEN_PATTERNS) {
182
+ if (forbidden.pattern.test(sourceCode)) {
183
+ failures.push({
184
+ code: 'forbidden-api',
185
+ message: `Candidate uses forbidden API: ${forbidden.label}.`,
186
+ detail: forbidden.label,
187
+ });
188
+ }
189
+ }
190
+
191
+ if (failures.length > 0) {
192
+ return {
193
+ passed: false,
194
+ failures,
195
+ helperUsage,
196
+ };
197
+ }
198
+
199
+ const normalizedSource = sourceCode;
200
+
201
+ try {
202
+ const moduleExports = loadRuleImplementationModule(normalizedSource, 'nocturnal-candidate.js') as {
203
+ meta?: unknown;
204
+ /* eslint-disable no-unused-vars -- Reason: type signature parameters, unused by design in function type definition */
205
+ evaluate?: (_input: RuleHostInput, _helpers: RuleHostHelpers) => unknown;
206
+ };
207
+
208
+ const metaFailures = validateMeta(moduleExports.meta);
209
+ failures.push(...metaFailures);
210
+
211
+ if (typeof moduleExports.evaluate !== 'function') {
212
+ failures.push({
213
+ code: 'missing-evaluate',
214
+ message: 'Candidate must export an evaluate function.',
215
+ });
216
+ } else {
217
+ const result = moduleExports.evaluate(
218
+ createValidationInput(),
219
+ createRuleHostHelpers(createValidationInput())
220
+ );
221
+ failures.push(...validateResult(result));
222
+ }
223
+
224
+ return {
225
+ passed: failures.length === 0,
226
+ failures,
227
+ helperUsage,
228
+ normalizedSource,
229
+ meta: metaFailures.length === 0 ? (moduleExports.meta as RuleHostMeta) : undefined,
230
+ };
231
+ } catch (error: unknown) {
232
+ return {
233
+ passed: false,
234
+ failures: [
235
+ {
236
+ code: 'compile-error',
237
+ message: 'Candidate could not be compiled with RuleHost-compatible semantics.',
238
+ detail: String(error),
239
+ },
240
+ ],
241
+ helperUsage,
242
+ normalizedSource,
243
+ };
244
+ }
245
+ }