principles-disciple 1.72.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/INSTALL.md +1 -3
  2. package/openclaw.plugin.json +10 -5
  3. package/package.json +17 -19
  4. package/scripts/acceptance-test.mjs +16 -73
  5. package/scripts/sync-plugin.mjs +382 -77
  6. package/src/commands/archive-impl.ts +2 -1
  7. package/src/commands/capabilities.ts +2 -2
  8. package/src/commands/context.ts +2 -2
  9. package/src/commands/disable-impl.ts +2 -1
  10. package/src/commands/evolution-status.ts +16 -16
  11. package/src/commands/export.ts +12 -67
  12. package/src/commands/pain.ts +91 -1
  13. package/src/commands/principle-rollback.ts +2 -1
  14. package/src/commands/promote-impl.ts +7 -43
  15. package/src/commands/rollback-impl.ts +2 -1
  16. package/src/commands/rollback.ts +2 -1
  17. package/src/commands/samples.ts +2 -1
  18. package/src/commands/thinking-os.ts +2 -1
  19. package/src/config/errors.ts +18 -2
  20. package/src/constants/diagnostician.ts +2 -2
  21. package/src/constants/tools.ts +2 -1
  22. package/src/core/__tests__/focus-history.test.ts +210 -0
  23. package/src/core/config.ts +1 -1
  24. package/src/core/correction-cue-learner.ts +2 -136
  25. package/src/core/correction-types.ts +16 -88
  26. package/src/core/dictionary.ts +19 -20
  27. package/src/core/empathy-keyword-matcher.ts +17 -289
  28. package/src/core/empathy-types.ts +18 -229
  29. package/src/core/event-log.ts +29 -132
  30. package/src/core/evolution-reducer.ts +21 -2
  31. package/src/core/evolution-types.ts +76 -464
  32. package/src/core/file-store.ts +80 -0
  33. package/src/core/focus-history.ts +228 -955
  34. package/src/core/local-worker-routing.ts +34 -314
  35. package/src/core/merge-gate-audit.ts +0 -195
  36. package/src/core/migration.ts +0 -1
  37. package/src/core/pain-diagnostic-gate.ts +154 -0
  38. package/src/core/pain-signal.ts +21 -138
  39. package/src/core/pain.ts +15 -88
  40. package/src/core/path-resolver.ts +0 -1
  41. package/src/core/paths.ts +0 -1
  42. package/src/core/pd-task-reconciler.ts +26 -115
  43. package/src/core/pd-task-service.ts +9 -9
  44. package/src/core/pd-task-types.ts +23 -127
  45. package/src/core/principle-compiler/__tests__/compiler-replay-gate.test.ts +174 -0
  46. package/src/core/principle-compiler/code-validator.ts +15 -42
  47. package/src/core/principle-compiler/compiler.ts +100 -15
  48. package/src/core/principle-compiler/index.ts +5 -2
  49. package/src/core/principle-compiler/template-generator.ts +4 -104
  50. package/src/core/principle-injection.ts +10 -202
  51. package/src/core/principle-internalization/filesystem-lifecycle-datasource.ts +42 -0
  52. package/src/core/principle-internalization/lifecycle-read-model.ts +39 -242
  53. package/src/core/principle-internalization/principle-lifecycle-service.ts +12 -10
  54. package/src/core/principle-tree-ledger-adapter.ts +145 -0
  55. package/src/core/principle-tree-ledger.ts +8 -6
  56. package/src/core/reflection/reflection-context.ts +14 -109
  57. package/src/core/replay-engine.ts +8 -500
  58. package/src/core/rule-host-helpers.ts +5 -35
  59. package/src/core/rule-host-types.ts +10 -82
  60. package/src/core/rule-host.ts +6 -63
  61. package/src/core/runtime-v2-prompt-activation-reader.ts +231 -0
  62. package/src/core/session-tracker.ts +87 -101
  63. package/src/core/shadow-observation-registry.ts +19 -48
  64. package/src/core/trajectory.ts +3 -1
  65. package/src/core/workflow-funnel-loader.ts +62 -68
  66. package/src/core/workspace-context.ts +46 -0
  67. package/src/core/workspace-dir-service.ts +1 -1
  68. package/src/core/workspace-dir-validation.ts +18 -9
  69. package/src/hooks/AGENTS.md +1 -1
  70. package/src/hooks/gate-block-helper.ts +71 -64
  71. package/src/hooks/gate.ts +183 -31
  72. package/src/hooks/lifecycle.ts +30 -32
  73. package/src/hooks/llm.ts +60 -32
  74. package/src/hooks/pain.ts +297 -103
  75. package/src/hooks/prompt.ts +400 -440
  76. package/src/hooks/subagent.ts +2 -29
  77. package/src/i18n/commands.ts +2 -10
  78. package/src/index.ts +95 -85
  79. package/src/openclaw-sdk.ts +311 -0
  80. package/src/service/central-database.ts +8 -4
  81. package/src/service/evolution-queue-migration.ts +2 -1
  82. package/src/service/evolution-worker.ts +163 -1786
  83. package/src/service/internalization-trigger-adapter.ts +302 -0
  84. package/src/service/keyword-optimization-service.ts +4 -4
  85. package/src/service/monitoring-query-service.ts +1 -215
  86. package/src/service/queue-io.ts +60 -331
  87. package/src/service/runtime-summary-service.ts +59 -16
  88. package/src/service/subagent-workflow/index.ts +0 -41
  89. package/src/service/subagent-workflow/types.ts +9 -120
  90. package/src/service/subagent-workflow/workflow-store.ts +2 -119
  91. package/src/service/workflow-watchdog.ts +0 -43
  92. package/src/types/event-payload.ts +16 -74
  93. package/src/types/event-types.ts +38 -547
  94. package/src/types/hygiene-types.ts +7 -30
  95. package/src/types/principle-tree-schema.ts +20 -222
  96. package/src/types/queue.ts +15 -70
  97. package/src/types/runtime-summary.ts +5 -49
  98. package/src/utils/io.ts +8 -20
  99. package/src/utils/retry.ts +1 -1
  100. package/src/utils/shadow-fingerprint.ts +2 -2
  101. package/src/utils/workspace-resolver.ts +50 -0
  102. package/templates/langs/en/core/AGENTS.md +7 -7
  103. package/templates/langs/en/core/BOOT.md +1 -1
  104. package/templates/langs/en/core/HEARTBEAT.md +2 -2
  105. package/templates/langs/en/principles/THINKING_OS.md +3 -2
  106. package/templates/langs/en/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
  107. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
  108. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
  109. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
  110. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
  111. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
  112. package/templates/langs/en/skills/evolve-task/SKILL.md +3 -3
  113. package/templates/langs/en/skills/pd-cli-operator/SKILL.md +67 -0
  114. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +1 -1
  115. package/templates/langs/en/skills/pd-mentor/SKILL.md +2 -3
  116. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +17 -39
  117. package/templates/langs/en/skills/pd-runtime-v2/SKILL.md +61 -0
  118. package/templates/langs/zh/core/AGENTS.md +7 -7
  119. package/templates/langs/zh/core/BOOT.md +1 -1
  120. package/templates/langs/zh/core/HEARTBEAT.md +2 -2
  121. package/templates/langs/zh/principles/THINKING_OS.md +3 -2
  122. package/templates/langs/zh/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
  123. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
  124. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
  125. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/nocturnal-trinity-quality-enhancement.json +8 -8
  126. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
  127. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
  128. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
  129. package/templates/langs/zh/skills/ai-sprint-orchestration/test/run.test.mjs +21 -5
  130. package/templates/langs/zh/skills/evolve-task/SKILL.md +4 -4
  131. package/templates/langs/zh/skills/pd-cli-operator/SKILL.md +67 -0
  132. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +1 -1
  133. package/templates/langs/zh/skills/pd-mentor/SKILL.md +2 -3
  134. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +17 -38
  135. package/templates/langs/zh/skills/pd-runtime-v2/SKILL.md +61 -0
  136. package/tests/build-artifacts.test.ts +1 -3
  137. package/tests/commands/evolution-status.test.ts +0 -118
  138. package/tests/core/bootstrap-rules.test.ts +1 -1
  139. package/tests/core/config.test.ts +1 -1
  140. package/tests/core/event-log.test.ts +35 -0
  141. package/tests/core/evolution-engine.test.ts +610 -0
  142. package/tests/core/file-store.test.ts +102 -0
  143. package/tests/core/focus-history.test.ts +203 -11
  144. package/tests/core/merge-gate-audit.test.ts +2 -169
  145. package/tests/core/migration.test.ts +7 -7
  146. package/tests/core/model-deployment-registry.test.ts +7 -1
  147. package/tests/core/model-training-registry.test.ts +19 -0
  148. package/tests/core/observability.test.ts +0 -1
  149. package/tests/core/pain-diagnostic-gate.test.ts +498 -0
  150. package/tests/core/pain.test.ts +0 -1
  151. package/tests/core/path-resolver.test.ts +1 -1
  152. package/tests/core/paths-refactor.test.ts +0 -22
  153. package/tests/core/principle-internalization/deprecated-readiness.test.ts +2 -2
  154. package/tests/core/principle-internalization/lifecycle-metrics.test.ts +2 -2
  155. package/tests/core/principle-internalization/{internalization-routing-policy.test.ts → lifecycle-routing-policy.test.ts} +6 -6
  156. package/tests/core/principle-internalization/lineage-source-retired.test.ts +56 -0
  157. package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +1 -23
  158. package/tests/core/principle-tree-ledger-adapter.test.ts +253 -0
  159. package/tests/core/reflection-context.test.ts +0 -14
  160. package/tests/core/replay-engine.test.ts +127 -215
  161. package/tests/core/rule-host-helpers.test.ts +2 -2
  162. package/tests/core/rule-implementation-runtime.test.ts +0 -27
  163. package/tests/core/workflow-funnel-loader.test.ts +162 -0
  164. package/tests/core/workspace-context.test.ts +2 -2
  165. package/tests/core/workspace-dir-validation.test.ts +8 -1
  166. package/tests/core-anti-growth.test.ts +191 -0
  167. package/tests/hook-workspace-nextaction-contract.test.ts +42 -0
  168. package/tests/hooks/confirm-first-removal.test.ts +188 -0
  169. package/tests/hooks/gate-auto-correct-shadow.test.ts +310 -0
  170. package/tests/hooks/gate-auto-correct.test.ts +665 -0
  171. package/tests/hooks/gate-no-path-write-tool.test.ts +172 -0
  172. package/tests/hooks/gate-rule-host-pipeline.test.ts +2 -1
  173. package/tests/hooks/pain.test.ts +269 -12
  174. package/tests/hooks/prompt-characterization.test.ts +500 -0
  175. package/tests/hooks/prompt-size-guard.test.ts +32 -17
  176. package/tests/hooks/runtime-v2-prompt-activation.test.ts +869 -0
  177. package/tests/index.test.ts +94 -1
  178. package/tests/integration/auto-entry-gate.test.ts +248 -0
  179. package/tests/integration/internalization-trigger-guard.test.ts +69 -0
  180. package/tests/integration/m8-legacy-paths.test.ts +63 -0
  181. package/tests/integration/runtime-v2-pain-guard.test.ts +125 -0
  182. package/tests/plugin-config-resolution-cutover.test.ts +359 -0
  183. package/tests/runtime-v2-discovery-guard.test.ts +154 -0
  184. package/tests/service/central-database.test.ts +457 -0
  185. package/tests/service/evolution-worker.correction-observer.test.ts +173 -0
  186. package/tests/service/evolution-worker.timeout.test.ts +11 -129
  187. package/tests/service/internalization-trigger-adapter.test.ts +251 -0
  188. package/tests/service/monitoring-query-service.test.ts +1 -47
  189. package/tests/service/queue-io.test.ts +1 -62
  190. package/tests/service/runtime-summary-service.test.ts +3 -1
  191. package/tests/service/workflow-watchdog.test.ts +0 -91
  192. package/tests/utils/file-lock.test.ts +5 -3
  193. package/tests/utils/session-key.test.ts +52 -0
  194. package/tests/utils/subagent-probe.test.ts +48 -1
  195. package/vitest.config.ts +4 -11
  196. package/.planning/codebase/ARCHITECTURE.md +0 -157
  197. package/.planning/codebase/CONCERNS.md +0 -145
  198. package/.planning/codebase/CONVENTIONS.md +0 -148
  199. package/.planning/codebase/INTEGRATIONS.md +0 -81
  200. package/.planning/codebase/STACK.md +0 -87
  201. package/.planning/codebase/STRUCTURE.md +0 -193
  202. package/.planning/codebase/TESTING.md +0 -243
  203. package/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +0 -113
  204. package/docs/COMMAND_REFERENCE.md +0 -76
  205. package/docs/COMMAND_REFERENCE_EN.md +0 -79
  206. package/scripts/build-web.mjs +0 -46
  207. package/scripts/diagnose-nocturnal.mjs +0 -537
  208. package/scripts/seed-nocturnal-scenarios.mjs +0 -384
  209. package/src/commands/nocturnal-review.ts +0 -322
  210. package/src/commands/nocturnal-rollout.ts +0 -790
  211. package/src/commands/nocturnal-train.ts +0 -986
  212. package/src/commands/pd-reflect.ts +0 -88
  213. package/src/core/adaptive-thresholds.ts +0 -478
  214. package/src/core/diagnostician-task-store.ts +0 -192
  215. package/src/core/nocturnal-arbiter.ts +0 -715
  216. package/src/core/nocturnal-artifact-lineage.ts +0 -116
  217. package/src/core/nocturnal-artificer.ts +0 -257
  218. package/src/core/nocturnal-candidate-scoring.ts +0 -530
  219. package/src/core/nocturnal-compliance.ts +0 -1146
  220. package/src/core/nocturnal-dataset.ts +0 -763
  221. package/src/core/nocturnal-executability.ts +0 -428
  222. package/src/core/nocturnal-export.ts +0 -499
  223. package/src/core/nocturnal-paths.ts +0 -240
  224. package/src/core/nocturnal-reasoning-deriver.ts +0 -343
  225. package/src/core/nocturnal-rule-implementation-validator.ts +0 -246
  226. package/src/core/nocturnal-snapshot-contract.ts +0 -99
  227. package/src/core/nocturnal-trajectory-extractor.ts +0 -512
  228. package/src/core/nocturnal-trinity-types.ts +0 -218
  229. package/src/core/nocturnal-trinity.ts +0 -2680
  230. package/src/core/principle-internalization/deprecated-readiness.ts +0 -93
  231. package/src/core/principle-internalization/internalization-routing-policy.ts +0 -208
  232. package/src/core/principle-internalization/lifecycle-metrics.ts +0 -152
  233. package/src/http/principles-console-route.ts +0 -709
  234. package/src/service/central-health-service.ts +0 -49
  235. package/src/service/central-overview-service.ts +0 -138
  236. package/src/service/control-ui-query-service.ts +0 -900
  237. package/src/service/cooldown-strategy.ts +0 -97
  238. package/src/service/evolution-pain-context.ts +0 -79
  239. package/src/service/evolution-query-service.ts +0 -407
  240. package/src/service/health-query-service.ts +0 -1038
  241. package/src/service/nocturnal-config.ts +0 -214
  242. package/src/service/nocturnal-runtime.ts +0 -734
  243. package/src/service/nocturnal-service.ts +0 -1605
  244. package/src/service/nocturnal-target-selector.ts +0 -545
  245. package/src/service/sleep-cycle.ts +0 -157
  246. package/src/service/startup-reconciler.ts +0 -112
  247. package/src/service/subagent-workflow/correction-observer-types.ts +0 -82
  248. package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +0 -250
  249. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +0 -1
  250. package/src/service/subagent-workflow/dynamic-timeout.ts +0 -30
  251. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +0 -268
  252. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -795
  253. package/src/service/subagent-workflow/runtime-direct-driver.ts +0 -268
  254. package/src/service/subagent-workflow/workflow-manager-base.ts +0 -580
  255. package/src/tools/write-pain-flag.ts +0 -215
  256. package/templates/langs/en/skills/plan-script/SKILL.md +0 -32
  257. package/templates/langs/zh/skills/plan-script/SKILL.md +0 -32
  258. package/tests/commands/nocturnal-review.test.ts +0 -448
  259. package/tests/commands/nocturnal-train.test.ts +0 -97
  260. package/tests/commands/pd-reflect.test.ts +0 -49
  261. package/tests/core/adaptive-thresholds.test.ts +0 -261
  262. package/tests/core/nocturnal-arbiter.test.ts +0 -559
  263. package/tests/core/nocturnal-artifact-lineage.test.ts +0 -53
  264. package/tests/core/nocturnal-artificer.test.ts +0 -241
  265. package/tests/core/nocturnal-candidate-scoring.test.ts +0 -532
  266. package/tests/core/nocturnal-compliance-p-principles.test.ts +0 -133
  267. package/tests/core/nocturnal-compliance.test.ts +0 -646
  268. package/tests/core/nocturnal-dataset.test.ts +0 -892
  269. package/tests/core/nocturnal-e2e.test.ts +0 -234
  270. package/tests/core/nocturnal-executability.test.ts +0 -357
  271. package/tests/core/nocturnal-export.test.ts +0 -517
  272. package/tests/core/nocturnal-reasoning-deriver.test.ts +0 -372
  273. package/tests/core/nocturnal-reviewed-subset-comparison.test.ts +0 -428
  274. package/tests/core/nocturnal-rule-implementation-validator.test.ts +0 -127
  275. package/tests/core/nocturnal-snapshot-contract.test.ts +0 -121
  276. package/tests/core/nocturnal-trajectory-extractor.test.ts +0 -634
  277. package/tests/core/nocturnal-trinity.test.ts +0 -2053
  278. package/tests/core/pain-auto-repair.test.ts +0 -96
  279. package/tests/core/pain-integration.test.ts +0 -510
  280. package/tests/fixtures/nocturnal-reviewed-subset.json +0 -183
  281. package/tests/http/principles-console-route.test.ts +0 -162
  282. package/tests/integration/chaos-resilience.test.ts +0 -348
  283. package/tests/integration/empathy-workflow-integration.test.ts +0 -626
  284. package/tests/integration/pain-diagnostician-loop.e2e.test.ts +0 -380
  285. package/tests/service/control-ui-query-service.test.ts +0 -121
  286. package/tests/service/cooldown-strategy.test.ts +0 -164
  287. package/tests/service/data-endpoints-regression.test.ts +0 -834
  288. package/tests/service/empathy-observer-workflow-manager.test.ts +0 -175
  289. package/tests/service/evolution-worker.nocturnal.test.ts +0 -601
  290. package/tests/service/nocturnal-runtime-hardening.test.ts +0 -118
  291. package/tests/service/nocturnal-runtime.test.ts +0 -473
  292. package/tests/service/nocturnal-service-code-candidate.test.ts +0 -330
  293. package/tests/service/nocturnal-target-selector.test.ts +0 -615
  294. package/tests/service/startup-reconciler.test.ts +0 -148
  295. package/tests/tools/write-pain-flag.test.ts +0 -358
  296. package/ui/src/App.tsx +0 -45
  297. package/ui/src/api.ts +0 -220
  298. package/ui/src/charts.tsx +0 -955
  299. package/ui/src/components/ErrorState.tsx +0 -6
  300. package/ui/src/components/Loading.tsx +0 -13
  301. package/ui/src/components/ProtectedRoute.tsx +0 -12
  302. package/ui/src/components/Shell.tsx +0 -91
  303. package/ui/src/components/WorkspaceConfig.tsx +0 -178
  304. package/ui/src/components/index.ts +0 -5
  305. package/ui/src/context/auth.tsx +0 -80
  306. package/ui/src/context/theme.tsx +0 -66
  307. package/ui/src/hooks/useAutoRefresh.ts +0 -39
  308. package/ui/src/i18n/ui.ts +0 -473
  309. package/ui/src/main.tsx +0 -16
  310. package/ui/src/pages/EvolutionPage.tsx +0 -333
  311. package/ui/src/pages/FeedbackPage.tsx +0 -138
  312. package/ui/src/pages/GateMonitorPage.tsx +0 -136
  313. package/ui/src/pages/LoginPage.tsx +0 -89
  314. package/ui/src/pages/OverviewPage.tsx +0 -599
  315. package/ui/src/pages/SamplesPage.tsx +0 -174
  316. package/ui/src/pages/ThinkingModelsPage.tsx +0 -702
  317. package/ui/src/styles.css +0 -2020
  318. package/ui/src/types.ts +0 -384
  319. package/ui/src/utils/format.ts +0 -15
@@ -1,734 +0,0 @@
1
- /**
2
- * Nocturnal Runtime Service — Idle Detection Source of Truth
3
- * ===========================================================
4
- *
5
- * This module is the authoritative source for workspace idle state used by the
6
- * nocturnal reflection pipeline. It must NOT use `.last_active.json` as the primary
7
- * source of truth.
8
- *
9
- * SOURCE OF TRUTH HIERARCHY (ordered by priority):
10
- * 1. SessionState.lastActivityAt — via listSessions(workspaceDir)
11
- * 2. trajectory timestamps — secondary guardrail only, NOT primary
12
- * 3. nocturnal-runtime.json — cooldown/quota bookkeeping (ephemeral state)
13
- *
14
- * DESIGN CONSTRAINTS:
15
- * - No `.last_active.json` as primary idle source
16
- * - trajectory timestamps are a guardrail, not the primary source
17
- * - cooldown/quota state is persisted in nocturnal-runtime.json
18
- * - abandoned sessions (>2h inactive) must not block nocturnal flow
19
- */
20
-
21
- import * as fs from 'fs';
22
- import * as path from 'path';
23
- import type { SessionState } from '../core/session-tracker.js';
24
- import { listSessions } from '../core/session-tracker.js';
25
- import { withLockAsync } from '../utils/file-lock.js';
26
- import { atomicWriteFileSync } from '../utils/io.js';
27
- import { DEFAULT_IDLE_THRESHOLD_MS, DEFAULT_QUOTA_WINDOW_MS } from '../config/defaults/runtime.js';
28
-
29
- // ---------------------------------------------------------------------------
30
- // System Session Detection
31
- // ---------------------------------------------------------------------------
32
-
33
- /**
34
- * Returns true if the session was created by a system process (cron, boot, probe, subagent, acp).
35
- * Uses OpenClaw's native session key patterns to avoid false positives.
36
- *
37
- * Detection priority (most reliable first):
38
- * 1. trigger field: Most reliable — explicitly set by OpenClaw ("cron", "heartbeat", "subagent")
39
- * 2. sessionKey patterns: Secondary confirmation via structured key (agent:main:cron:...)
40
- * 3. sessionId prefix: Fallback for boot-, probe- prefixed IDs
41
- *
42
- * Excluded (NOT system sessions):
43
- * - User sessions like agent:main:feishu:user:xxx — third component is channel type
44
- */
45
-
46
- function isSystemSession(state: SessionState): boolean {
47
- const { sessionId, sessionKey, trigger } = state;
48
-
49
- // Primary: trigger field is explicitly set by OpenClaw - most reliable
50
- if (trigger === 'cron' || trigger === 'heartbeat' || trigger === 'subagent') {
51
- return true;
52
- }
53
-
54
- // Secondary: sessionKey pattern matching
55
- if (sessionKey) {
56
- const raw = sessionKey.toLowerCase();
57
- if (raw.includes('cron:')) return true;
58
- if (raw.includes('subagent:')) return true;
59
- if (raw.includes('acp:')) return true;
60
- }
61
-
62
- // Fallback: sessionId prefix patterns (boot-, probe-)
63
- if (sessionId?.startsWith('boot-')) return true;
64
- if (sessionId?.startsWith('probe-')) return true;
65
-
66
- // CRITICAL FIX: Legacy sessions from persistence may have missing trigger/sessionKey
67
- // If both are missing AND the session is old (inactive > abandoned threshold),
68
- // treat as legacy/orphan to avoid blocking idle detection with unknown sessions.
69
- // Recent sessions without trigger/sessionKey are likely real user sessions still
70
- // being enriched — do NOT classify them as system sessions.
71
- const ABANDONED_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
72
- if (!trigger && !sessionKey) {
73
- const inactiveFor = Date.now() - state.lastActivityAt;
74
- if (inactiveFor > ABANDONED_THRESHOLD_MS) {
75
- return true; // Legacy/orphan session — don't block idle detection
76
- }
77
- // Recent session without metadata — likely a real user session, let it through
78
- }
79
-
80
- return false;
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Constants
85
- // ---------------------------------------------------------------------------
86
-
87
- /** File name for nocturnal runtime bookkeeping */
88
- export const NOCTURNAL_RUNTIME_FILE = 'nocturnal-runtime.json';
89
-
90
- /** Default cooldown between nocturnal runs (ms) */
91
- export const DEFAULT_GLOBAL_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
92
-
93
- /** Default per-principle cooldown (ms) */
94
- export const DEFAULT_PRINCIPLE_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours
95
-
96
- /** Default maximum nocturnal runs per quota window */
97
- export const DEFAULT_MAX_RUNS_PER_WINDOW = 3;
98
-
99
- /** Abandoned session threshold: sessions inactive for longer than this are ignored (ms) */
100
- export const DEFAULT_ABANDONED_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
101
-
102
- // ---------------------------------------------------------------------------
103
- // Types
104
- // ---------------------------------------------------------------------------
105
-
106
- /** Per-task-kind failure tracking for cooldown escalation (Phase 40) */
107
- export interface TaskFailureState {
108
- /** Number of consecutive failures for this task kind */
109
- consecutiveFailures: number;
110
- /** Current escalation tier: 0=none, 1=30min, 2=4h, 3=24h (cap) */
111
- escalationTier: number;
112
- /** Cooldown deadline as ISO string, undefined if not in cooldown */
113
- cooldownUntil?: string;
114
- }
115
-
116
- /**
117
- * Persisted state for nocturnal runtime bookkeeping.
118
- * Stored in {stateDir}/nocturnal-runtime.json
119
- */
120
- export interface NocturnalRuntimeState {
121
- /** Last time a nocturnal run was started (ISO string) */
122
- lastRunAt?: string;
123
-
124
- /** Last time a nocturnal run completed successfully */
125
- lastSuccessfulRunAt?: string;
126
-
127
- /** Cooldown end time for global cooldown (ISO string) */
128
- globalCooldownUntil?: string;
129
-
130
- /**
131
- * Per-principle cooldown map.
132
- * Key: principleId, Value: ISO string of cooldown end time
133
- */
134
- principleCooldowns: Record<string, string>;
135
-
136
- /**
137
- * Sliding window of recent run timestamps.
138
- * Used for quota enforcement.
139
- */
140
- recentRunTimestamps: string[];
141
-
142
- /**
143
- * Sliding window of keyword optimization run timestamps.
144
- * Separate from regular nocturnal runs to avoid quota pollution (fixes #321).
145
- */
146
- keywordOptRunTimestamps: string[];
147
-
148
- /** Metadata about last run (for debugging) */
149
- lastRunMeta?: {
150
- targetPrincipleId?: string;
151
- sampleCount?: number;
152
- status: 'success' | 'failed' | 'skipped';
153
- reason?: string;
154
- };
155
-
156
- /**
157
- * Per-task-kind failure tracking for cooldown escalation.
158
- * Key: taskKind string (e.g. 'sleep_reflection', 'keyword_optimization')
159
- * Value: failure state with consecutive count, tier, and cooldown deadline
160
- */
161
- taskFailureState?: Record<string, TaskFailureState>;
162
- }
163
-
164
- /** Result of an idle check */
165
- export interface IdleCheckResult {
166
- /** Whether the workspace is currently idle */
167
- isIdle: boolean;
168
- /** Most recent activity timestamp across all sessions (epoch ms) */
169
- mostRecentActivityAt: number;
170
- /** How long since the last activity (ms) */
171
- idleForMs: number;
172
- /** Number of active (non-abandoned) user sessions found */
173
- userActiveSessions: number;
174
- /** List of abandoned session IDs (inactive > abandoned threshold) */
175
- abandonedSessionIds: string[];
176
- /** Whether trajectory guardrail also confirms idle */
177
- trajectoryGuardrailConfirmsIdle: boolean;
178
- /** Reason for the idle determination */
179
- reason: string;
180
- }
181
-
182
- /** Result of a cooldown check */
183
- export interface CooldownCheckResult {
184
- /** Whether the global cooldown is currently active */
185
- globalCooldownActive: boolean;
186
- /** When the global cooldown ends (ISO string), null if not in cooldown */
187
- globalCooldownUntil: string | null;
188
- /** Remaining ms until global cooldown expires */
189
- globalCooldownRemainingMs: number;
190
- /** Whether the principle-specific cooldown is active */
191
- principleCooldownActive: boolean;
192
- /** When the principle cooldown ends (ISO string), null if not in cooldown */
193
- principleCooldownUntil: string | null;
194
- /** Remaining ms until principle cooldown expires */
195
- principleCooldownRemainingMs: number;
196
- /** Whether the quota has been exhausted */
197
- quotaExhausted: boolean;
198
- /** Number of runs remaining in current window */
199
- runsRemaining: number;
200
- }
201
-
202
- // ---------------------------------------------------------------------------
203
- // Default State
204
- // ---------------------------------------------------------------------------
205
-
206
- function createDefaultState(): NocturnalRuntimeState {
207
- return {
208
- principleCooldowns: {},
209
- recentRunTimestamps: [],
210
- keywordOptRunTimestamps: [],
211
- };
212
- }
213
-
214
- // ---------------------------------------------------------------------------
215
- // File Operations (with locking)
216
- // ---------------------------------------------------------------------------
217
-
218
- export async function readState(stateDir: string): Promise<NocturnalRuntimeState> {
219
- const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
220
- if (!fs.existsSync(filePath)) {
221
- return createDefaultState();
222
- }
223
- try {
224
- const raw = fs.readFileSync(filePath, 'utf-8');
225
- const parsed = JSON.parse(raw) as NocturnalRuntimeState;
226
- // Ensure required fields exist (migration-safe)
227
- return {
228
- principleCooldowns: parsed.principleCooldowns ?? {},
229
- recentRunTimestamps: parsed.recentRunTimestamps ?? [],
230
- keywordOptRunTimestamps: parsed.keywordOptRunTimestamps ?? [],
231
- lastRunAt: parsed.lastRunAt,
232
- lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
233
- globalCooldownUntil: parsed.globalCooldownUntil,
234
- lastRunMeta: parsed.lastRunMeta,
235
- taskFailureState: parsed.taskFailureState,
236
- };
237
- } catch {
238
- // Corrupted file — start fresh
239
- return createDefaultState();
240
- }
241
- }
242
-
243
- export function readStateSync(stateDir: string): NocturnalRuntimeState {
244
- const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
245
- if (!fs.existsSync(filePath)) {
246
- return createDefaultState();
247
- }
248
- try {
249
- const raw = fs.readFileSync(filePath, 'utf-8');
250
- const parsed = JSON.parse(raw) as NocturnalRuntimeState;
251
- return {
252
- principleCooldowns: parsed.principleCooldowns ?? {},
253
- recentRunTimestamps: parsed.recentRunTimestamps ?? [],
254
- keywordOptRunTimestamps: parsed.keywordOptRunTimestamps ?? [],
255
- lastRunAt: parsed.lastRunAt,
256
- lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
257
- globalCooldownUntil: parsed.globalCooldownUntil,
258
- lastRunMeta: parsed.lastRunMeta,
259
- taskFailureState: parsed.taskFailureState,
260
- };
261
- } catch (err) {
262
- console.warn(`[nocturnal-runtime] State file corrupted, resetting: ${err instanceof Error ? err.message : String(err)}`);
263
- return createDefaultState();
264
- }
265
- }
266
-
267
- export async function writeState(stateDir: string, state: NocturnalRuntimeState): Promise<void> {
268
- const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
269
- const stateDirPath = path.dirname(filePath);
270
- if (!fs.existsSync(stateDirPath)) {
271
- fs.mkdirSync(stateDirPath, { recursive: true });
272
- }
273
- await withLockAsync(filePath, async () => {
274
- atomicWriteFileSync(filePath, JSON.stringify(state, null, 2));
275
- });
276
- }
277
-
278
- // ---------------------------------------------------------------------------
279
- // Idle Detection
280
- // ---------------------------------------------------------------------------
281
-
282
- /**
283
- * Check if the workspace is currently idle based on session activity.
284
- *
285
- * IDLE DETERMINATION LOGIC:
286
- * - Collect all sessions for the workspace via listSessions()
287
- * - Filter out abandoned sessions (inactive > abandonedThresholdMs)
288
- * - Workspace is idle if: no active sessions OR all active sessions have lastActivityAt older than idleThresholdMs
289
- * - Abandoned sessions do NOT contribute to idle determination
290
- *
291
- * @param workspaceDir - Workspace directory to check
292
- * @param options.idleThresholdMs - Consider idle if no activity for this duration (default: 30 min)
293
- * @param options.abandonedThresholdMs - Consider session abandoned if inactive for this duration (default: 2 hr)
294
- * @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
295
- * @returns IdleCheckResult with full diagnostic information
296
- */
297
-
298
- export function checkWorkspaceIdle(
299
- workspaceDir: string,
300
- options: {
301
- idleThresholdMs?: number;
302
- abandonedThresholdMs?: number;
303
- } = {},
304
- trajectoryLastActivityAt?: number
305
- ): IdleCheckResult {
306
- const {
307
- idleThresholdMs = DEFAULT_IDLE_THRESHOLD_MS,
308
- abandonedThresholdMs = DEFAULT_ABANDONED_THRESHOLD_MS,
309
- } = options;
310
-
311
- const now = Date.now();
312
- const sessions = listSessions(workspaceDir);
313
-
314
- // Separate active vs abandoned sessions
315
- const abandonedSessions: string[] = [];
316
- let mostRecentActivityAt = 0;
317
- let userActiveSessions = 0;
318
-
319
- for (const session of sessions) {
320
- // Skip system sessions (cron, boot, probe, subagent, acp) from idle determination
321
- if (isSystemSession(session)) continue;
322
-
323
- const inactiveFor = now - session.lastActivityAt;
324
- if (inactiveFor > abandonedThresholdMs) {
325
- abandonedSessions.push(session.sessionId);
326
- } else {
327
- userActiveSessions++;
328
- if (session.lastActivityAt > mostRecentActivityAt) {
329
- mostRecentActivityAt = session.lastActivityAt;
330
- }
331
- }
332
- }
333
-
334
- const idleForMs = mostRecentActivityAt > 0 ? now - mostRecentActivityAt : now;
335
- const isIdle = mostRecentActivityAt === 0 || idleForMs > idleThresholdMs;
336
-
337
- // Trajectory guardrail: only used as a secondary check
338
- // If trajectory says there's recent activity but session state says idle,
339
- // that's a discrepancy we should note but still trust session state as primary
340
- let trajectoryGuardrailConfirmsIdle = true;
341
- if (trajectoryLastActivityAt !== undefined) {
342
- const trajectoryIdleFor = now - trajectoryLastActivityAt;
343
- // Guardrail confirms if trajectory also shows idle or near-idle (>80% of threshold)
344
- trajectoryGuardrailConfirmsIdle = trajectoryIdleFor > idleThresholdMs * 0.8;
345
- }
346
-
347
-
348
-
349
- let reason: string;
350
- if (mostRecentActivityAt === 0) {
351
- reason = 'No active sessions found — workspace is idle';
352
- } else if (isIdle) {
353
- reason = `Most recent activity ${idleForMs}ms ago (>${idleThresholdMs}ms threshold)`;
354
- } else {
355
- reason = `Recent activity ${idleForMs}ms ago (<${idleThresholdMs}ms threshold)`;
356
- }
357
-
358
- if (abandonedSessions.length > 0) {
359
- reason += `; ${abandonedSessions.length} abandoned session(s) ignored`;
360
- }
361
-
362
- return {
363
- isIdle,
364
- mostRecentActivityAt,
365
- idleForMs,
366
- userActiveSessions,
367
- abandonedSessionIds: abandonedSessions,
368
- trajectoryGuardrailConfirmsIdle,
369
- reason,
370
- };
371
- }
372
-
373
- // ---------------------------------------------------------------------------
374
- // Cooldown Management
375
- // ---------------------------------------------------------------------------
376
-
377
- /**
378
- * Check if the workspace is currently in a cooldown period.
379
- *
380
- * @param stateDir - State directory
381
- * @param principleId - Optional principle ID to check per-principle cooldown
382
- * @param options - Cooldown configuration options
383
- * @returns CooldownCheckResult
384
- */
385
-
386
- export function checkCooldown(
387
- stateDir: string,
388
- principleId?: string,
389
- options: {
390
- globalCooldownMs?: number;
391
- principleCooldownMs?: number;
392
- maxRunsPerWindow?: number;
393
- quotaWindowMs?: number;
394
- } = {}
395
- ): CooldownCheckResult {
396
- const {
397
- maxRunsPerWindow = DEFAULT_MAX_RUNS_PER_WINDOW,
398
- quotaWindowMs = DEFAULT_QUOTA_WINDOW_MS,
399
- } = options;
400
-
401
- const now = Date.now();
402
- const state = readStateSync(stateDir);
403
-
404
- // Global cooldown check
405
- let globalCooldownActive = false;
406
- let globalCooldownRemainingMs = 0;
407
- let globalCooldownUntil: string | null = null;
408
-
409
- if (state.globalCooldownUntil) {
410
- const cooldownEnd = new Date(state.globalCooldownUntil).getTime();
411
- if (cooldownEnd > now) {
412
- globalCooldownActive = true;
413
- globalCooldownRemainingMs = cooldownEnd - now;
414
-
415
- globalCooldownUntil = state.globalCooldownUntil;
416
- }
417
- }
418
-
419
- // Principle-specific cooldown check
420
- let principleCooldownActive = false;
421
- let principleCooldownRemainingMs = 0;
422
- let principleCooldownUntil: string | null = null;
423
-
424
- if (principleId && state.principleCooldowns[principleId]) {
425
- const cooldownEnd = new Date(state.principleCooldowns[principleId]).getTime();
426
- if (cooldownEnd > now) {
427
- principleCooldownActive = true;
428
- principleCooldownRemainingMs = cooldownEnd - now;
429
- principleCooldownUntil = state.principleCooldowns[principleId];
430
- }
431
- }
432
-
433
- // Quota check: count runs in sliding window
434
- const windowStart = now - quotaWindowMs;
435
- const recentRuns = state.recentRunTimestamps
436
- .map(ts => new Date(ts).getTime())
437
- .filter(ts => ts > windowStart);
438
-
439
- const quotaExhausted = recentRuns.length >= maxRunsPerWindow;
440
- const runsRemaining = Math.max(0, maxRunsPerWindow - recentRuns.length);
441
-
442
- return {
443
- globalCooldownActive,
444
- globalCooldownUntil,
445
- globalCooldownRemainingMs,
446
- principleCooldownActive,
447
- principleCooldownUntil,
448
- principleCooldownRemainingMs,
449
- quotaExhausted,
450
- runsRemaining,
451
- };
452
- }
453
-
454
- /**
455
- * Check keyword optimization cooldown using its dedicated timestamp array.
456
- * Fixes #321: keyword optimization quota must not be polluted by regular nocturnal runs.
457
- *
458
- * @param stateDir - State directory
459
- * @param options - Quota options (default: 4 runs per 24 hours)
460
- */
461
- export function checkKeywordOptCooldown(
462
- stateDir: string,
463
- options: {
464
- maxRunsPerWindow?: number;
465
- quotaWindowMs?: number;
466
- } = {}
467
- ): CooldownCheckResult {
468
- const {
469
- maxRunsPerWindow = 4,
470
- quotaWindowMs = 24 * 60 * 60 * 1000,
471
- } = options;
472
-
473
- const now = Date.now();
474
- const state = readStateSync(stateDir);
475
-
476
- const windowStart = now - quotaWindowMs;
477
- const recentKeywordOpts: number[] = [];
478
- for (const ts of state.keywordOptRunTimestamps) {
479
- const parsed = new Date(ts).getTime();
480
- if (Number.isNaN(parsed)) {
481
- console.warn(`[NocturnalRuntime] Malformed timestamp in keywordOptRunTimestamps: "${ts}"`);
482
- continue;
483
- }
484
- if (parsed > windowStart) {
485
- recentKeywordOpts.push(parsed);
486
- }
487
- }
488
-
489
- // Keyword optimization uses a dedicated quota (keywordOptRunTimestamps)
490
- // separate from the regular nocturnal run quota (runStartTimestamps).
491
- // Global/principle cooldowns from regular nocturnal runs do NOT apply here.
492
- return {
493
- globalCooldownActive: false,
494
- globalCooldownUntil: null,
495
- globalCooldownRemainingMs: 0,
496
- principleCooldownActive: false,
497
- principleCooldownUntil: null,
498
- principleCooldownRemainingMs: 0,
499
- quotaExhausted: recentKeywordOpts.length >= maxRunsPerWindow,
500
- runsRemaining: Math.max(0, maxRunsPerWindow - recentKeywordOpts.length),
501
- };
502
- }
503
-
504
- /**
505
- * Record a keyword optimization run in its dedicated timestamp array.
506
- * Does NOT affect regular nocturnal quota tracking.
507
- *
508
- * @param stateDir - State directory
509
- * @param quotaWindowMs - Window size in ms (default: 24 hours)
510
- */
511
- export async function recordKeywordOptRun(
512
- stateDir: string,
513
- quotaWindowMs: number = 24 * 60 * 60 * 1000
514
- ): Promise<void> {
515
- const state = await readState(stateDir);
516
- const now = new Date().toISOString();
517
-
518
- state.keywordOptRunTimestamps.push(now);
519
-
520
- const windowStart = Date.now() - quotaWindowMs;
521
- state.keywordOptRunTimestamps = state.keywordOptRunTimestamps
522
- .map(ts => new Date(ts).getTime())
523
- .filter(ts => ts > windowStart)
524
- .map(ts => new Date(ts).toISOString());
525
-
526
- await writeState(stateDir, state);
527
- }
528
-
529
- /**
530
- * Records a cooldown event for quota tracking (keyword_optimization etc.).
531
- * Adds a timestamp to recentRunTimestamps and prunes entries outside the window.
532
- * Does NOT set globalCooldownUntil — callers that need it should call recordRunStart.
533
- *
534
- * @param stateDir - State directory
535
- * @param quotaWindowMs - Window size in ms (default: 24 hours)
536
- */
537
- export async function recordCooldown(
538
- stateDir: string,
539
- quotaWindowMs: number = 24 * 60 * 60 * 1000
540
- ): Promise<void> {
541
- const state = await readState(stateDir);
542
- const now = new Date().toISOString();
543
-
544
- state.recentRunTimestamps.push(now);
545
-
546
- // Prune old timestamps outside the window
547
- const windowStart = Date.now() - quotaWindowMs;
548
- state.recentRunTimestamps = state.recentRunTimestamps
549
- .map(ts => new Date(ts).getTime())
550
- .filter(ts => ts > windowStart)
551
- .map(ts => new Date(ts).toISOString());
552
-
553
- await writeState(stateDir, state);
554
- }
555
-
556
- /**
557
- * Record that a nocturnal run has started.
558
- * Updates global cooldown and quota tracking.
559
- *
560
- * @param stateDir - State directory
561
- * @param principleId - Target principle ID for this run
562
- * @param cooldownMs - Global cooldown duration in ms (default: 1 hour)
563
- */
564
- export async function recordRunStart(
565
- stateDir: string,
566
- principleId: string,
567
- cooldownMs: number = DEFAULT_GLOBAL_COOLDOWN_MS
568
- ): Promise<void> {
569
- const state = await readState(stateDir);
570
- const now = new Date().toISOString();
571
-
572
- state.lastRunAt = now;
573
- state.lastRunMeta = {
574
- targetPrincipleId: principleId,
575
- status: 'skipped', // Will be updated on completion
576
- };
577
-
578
- // Set global cooldown (use configured value, not hardcoded default)
579
- const cooldownUntil = new Date(Date.now() + cooldownMs).toISOString();
580
- state.globalCooldownUntil = cooldownUntil;
581
-
582
- // Add to recent runs for quota tracking
583
- state.recentRunTimestamps.push(now);
584
-
585
- // Prune old timestamps outside the quota window
586
- const windowStart = Date.now() - DEFAULT_QUOTA_WINDOW_MS;
587
- state.recentRunTimestamps = state.recentRunTimestamps
588
- .map(ts => new Date(ts).getTime())
589
- .filter(ts => ts > windowStart)
590
- .map(ts => new Date(ts).toISOString());
591
-
592
- await writeState(stateDir, state);
593
- }
594
-
595
- /**
596
- * Record the outcome of a nocturnal run.
597
- *
598
- * @param stateDir - State directory
599
- * @param outcome - 'success', 'failed', or 'skipped'
600
- * @param details - Optional details about the run
601
- */
602
- export async function recordRunEnd(
603
- stateDir: string,
604
- outcome: 'success' | 'failed' | 'skipped',
605
- details?: {
606
- sampleCount?: number;
607
- reason?: string;
608
- }
609
- ): Promise<void> {
610
- const state = await readState(stateDir);
611
- const now = new Date().toISOString();
612
-
613
- if (outcome === 'success') {
614
- state.lastSuccessfulRunAt = now;
615
-
616
- // Also set per-principle cooldown if we know which principle was targeted
617
- if (state.lastRunMeta?.targetPrincipleId) {
618
- const pid = state.lastRunMeta.targetPrincipleId;
619
- state.principleCooldowns[pid] = new Date(
620
- Date.now() + DEFAULT_PRINCIPLE_COOLDOWN_MS
621
- ).toISOString();
622
- }
623
- }
624
-
625
- // Update run metadata
626
- state.lastRunMeta = {
627
- ...state.lastRunMeta,
628
- status: outcome,
629
- sampleCount: details?.sampleCount ?? state.lastRunMeta?.sampleCount,
630
- reason: details?.reason ?? state.lastRunMeta?.reason,
631
- };
632
-
633
- // Note: global cooldown remains active (set at run start) - we don't clear it on failure
634
- // This prevents rapid retry loops
635
-
636
- await writeState(stateDir, state);
637
- }
638
-
639
- /**
640
- * Clear all cooldowns (for testing or admin reset).
641
- *
642
- * @param stateDir - State directory
643
- */
644
- export async function clearAllCooldowns(stateDir: string): Promise<void> {
645
- const state = await readState(stateDir);
646
- state.globalCooldownUntil = undefined;
647
- state.principleCooldowns = {};
648
- state.recentRunTimestamps = [];
649
- state.keywordOptRunTimestamps = [];
650
- state.lastRunMeta = undefined;
651
- await writeState(stateDir, state);
652
- }
653
-
654
- /**
655
- * Get the current runtime state (for debugging/inspection).
656
- *
657
- * @param stateDir - State directory
658
- * @returns The current NocturnalRuntimeState
659
- */
660
- export async function getRuntimeState(stateDir: string): Promise<NocturnalRuntimeState> {
661
- return readState(stateDir);
662
- }
663
-
664
- // ---------------------------------------------------------------------------
665
- // Convenience: Full Pre-Flight Check
666
- // ---------------------------------------------------------------------------
667
-
668
- export interface PreflightCheckResult {
669
- canRun: boolean;
670
- idle: IdleCheckResult;
671
- cooldown: CooldownCheckResult;
672
- /**
673
- * Human-readable reasons why run is blocked (if canRun is false)
674
- */
675
- blockers: string[];
676
- }
677
-
678
- /**
679
- * Combined pre-flight check for whether a nocturnal run should proceed.
680
- * Integrates idle + cooldown + quota checks.
681
- *
682
- * @param workspaceDir - Workspace directory
683
- * @param stateDir - State directory
684
- * @param principleId - Target principle ID
685
- * @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
686
- * @param idleCheckOverride - Optional override for idle check result (for testing)
687
- */
688
-
689
-
690
- export function checkPreflight(
691
- workspaceDir: string,
692
- stateDir: string,
693
- principleId?: string,
694
- trajectoryLastActivityAt?: number,
695
- idleCheckOverride?: IdleCheckResult,
696
- skipGatesForManualTrigger?: boolean
697
- ): PreflightCheckResult {
698
- const idle = idleCheckOverride ?? checkWorkspaceIdle(workspaceDir, {}, trajectoryLastActivityAt);
699
- const cooldown = checkCooldown(stateDir, principleId);
700
-
701
- const blockers: string[] = [];
702
-
703
- if (!idle.isIdle) {
704
- blockers.push(`Workspace not idle (active for ${idle.idleForMs}ms, threshold=${DEFAULT_IDLE_THRESHOLD_MS}ms)`);
705
- }
706
-
707
- if (!skipGatesForManualTrigger) {
708
- if (cooldown.globalCooldownActive) {
709
- blockers.push(`Global cooldown active until ${cooldown.globalCooldownUntil}`);
710
- }
711
-
712
- if (cooldown.principleCooldownActive) {
713
- blockers.push(`Principle cooldown active until ${cooldown.principleCooldownUntil}`);
714
- }
715
-
716
- if (cooldown.quotaExhausted) {
717
- blockers.push(`Quota exhausted (${DEFAULT_MAX_RUNS_PER_WINDOW} runs per ${DEFAULT_QUOTA_WINDOW_MS / 3600000}h window)`);
718
- }
719
- } else if (cooldown.globalCooldownActive || cooldown.principleCooldownActive || cooldown.quotaExhausted) {
720
- // Log that gates are being bypassed for manual trigger
721
- }
722
-
723
- if (idle.abandonedSessionIds.length > 0 && idle.userActiveSessions === 0) {
724
- // Only block if ALL sessions are abandoned (meaning workspace truly has no activity)
725
- // If some sessions are active, we trust the session-based idle check
726
- }
727
-
728
- return {
729
- canRun: blockers.length === 0,
730
- idle,
731
- cooldown,
732
- blockers,
733
- };
734
- }