session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -45,6 +45,14 @@
45
45
  * - `surfaceTopN` (`./learnings/surface.mjs`) — the canonical active-learning
46
46
  * filter (confidence > floor, not expired); called with a large cap to get
47
47
  * the full active set rather than a top-N slice.
48
+ * - `countReconcileBacklog` (`./reconcile/backlog.mjs`) — the SAME eligibility +
49
+ * materialization partition `/reconcile` applies, imported from the LEAF module
50
+ * rather than from `./reconcile/engine.mjs`. The engine is WRITE-capable (it
51
+ * merges the candidate store) and drags the emitter, renderer and unicode
52
+ * validator behind it: importing the count from there took this probe's static
53
+ * closure from 11 modules / 151,946 B to 19 / 322,897 B (+112 %, measured
54
+ * 2026-09-18), inherited unasked by `maintenance-due-banner.mjs`. A banner must
55
+ * not be able to reach a writer.
48
56
  * - `filterEligible` (`./reconcile/eligibility.mjs`) — the SAME type/file_paths
49
57
  * allow-list gate the reconcile engine itself uses (deliberately NOT
50
58
  * confidence-gated — mirrors `runReconcile`'s own posture, see
@@ -68,7 +76,7 @@ import { join } from 'node:path';
68
76
 
69
77
  import { readLearnings } from './learnings/io.mjs';
70
78
  import { surfaceTopN } from './learnings/surface.mjs';
71
- import { filterEligible } from './reconcile/eligibility.mjs';
79
+ import { countReconcileBacklog } from './reconcile/backlog.mjs';
72
80
  import { loadCandidates } from './reconcile/idempotency.mjs';
73
81
  import { readConfigFile } from './config/io.mjs';
74
82
  import { _parseReconcile } from './config/reconcile.mjs';
@@ -82,9 +90,67 @@ export const NUDGE_MIN_LEARNINGS = 20;
82
90
  /** Nudge threshold (b): new learnings accrued since the last determinable reconcile run. */
83
91
  export const NUDGE_MIN_DELTA = 15;
84
92
 
85
- /** Nudge threshold (c): rule-eligible learnings (type + file_paths allow-list), regardless of confidence. */
93
+ /**
94
+ * Nudge threshold (c): reconcile BACKLOG — rule-eligible learnings (type + file_paths
95
+ * allow-list, not expired, regardless of confidence) MINUS those already materialized
96
+ * in the idempotency sidecar or a `.claude/rules/` provenance marker. Counting the
97
+ * materialized ones too made the probe un-greenable (#1380: 98 eligible, 35 already
98
+ * materialized, and no `/reconcile` run could ever clear it).
99
+ */
86
100
  export const NUDGE_MIN_ELIGIBLE = 3;
87
101
 
102
+ /**
103
+ * Resolve the `reconcile` Session Config settings this module needs, in ONE read.
104
+ *
105
+ * Both consumers go through here, so the nudge can never judge on a different
106
+ * population than `/reconcile` does: the skill reads `reconcile.min-insight-chars`
107
+ * (default 24) and forwards it to the engine, and so must this probe. Before #1380
108
+ * follow-up the value was only read AFTER the backlog had already been counted, which
109
+ * left the placeholder-insight gate practically OFF for the banner.
110
+ *
111
+ * Precedence: an injected already-parsed Session Config wins per KEY (DI seam, avoids a
112
+ * second CLAUDE.md read); any key it does not carry falls back to parsing
113
+ * CLAUDE.md/AGENTS.md; an unreadable config falls back to `_parseReconcile('')`, i.e.
114
+ * the parser's OWN documented defaults — never a literal repeated here.
115
+ *
116
+ * Never throws.
117
+ *
118
+ * @param {string} repoRoot
119
+ * @param {unknown} [config] already-parsed Session Config
120
+ * @returns {Promise<{enabled: boolean, minInsightChars: number}>}
121
+ */
122
+ async function _resolveReconcileSettings(repoRoot, config) {
123
+ const injected =
124
+ config && typeof config === 'object' && /** @type {any} */ (config).reconcile &&
125
+ typeof (/** @type {any} */ (config).reconcile) === 'object'
126
+ ? /** @type {Record<string, unknown>} */ (/** @type {any} */ (config).reconcile)
127
+ : null;
128
+
129
+ const hasEnabled = injected !== null && typeof injected.enabled === 'boolean';
130
+ const hasChars = injected !== null && Number.isFinite(injected['min-insight-chars']);
131
+ if (hasEnabled && hasChars) {
132
+ return {
133
+ enabled: /** @type {boolean} */ (injected.enabled),
134
+ minInsightChars: Number(injected['min-insight-chars']),
135
+ };
136
+ }
137
+
138
+ let parsed;
139
+ try {
140
+ parsed = _parseReconcile(await readConfigFile(repoRoot));
141
+ } catch {
142
+ // Config unreadable — take the parser's own defaults (single source of truth).
143
+ parsed = _parseReconcile('');
144
+ }
145
+
146
+ return {
147
+ enabled: hasEnabled ? /** @type {boolean} */ (injected.enabled) : parsed.enabled,
148
+ minInsightChars: hasChars
149
+ ? Number(injected['min-insight-chars'])
150
+ : parsed['min-insight-chars'],
151
+ };
152
+ }
153
+
88
154
  /**
89
155
  * Resolve the max `created_at` across reconcile-candidate sidecar records —
90
156
  * the most recent reconcile run's timestamp, or `null` when no run is on
@@ -110,11 +176,19 @@ function _lastRunAt(candidates) {
110
176
  *
111
177
  * @param {object} [opts]
112
178
  * @param {string} [opts.repoRoot] — project root (defaults to process.cwd()).
113
- * @param {Date|number} [opts.now] — injectable clock, forwarded to the active-learning filter.
179
+ * @param {Date|number} [opts.now] — injectable clock, forwarded to the active-learning
180
+ * filter AND the eligibility expiry gate.
181
+ * @param {number} [opts.minInsightChars] — forwarded to the eligibility filter. When
182
+ * OMITTED the value is read from Session Config (`reconcile.min-insight-chars`,
183
+ * default 24) rather than left inert, so the nudge and `/reconcile` count the SAME
184
+ * population — see `_resolveReconcileSettings`.
185
+ * @param {object} [opts.config] — optional already-parsed Session Config (DI seam).
114
186
  * @returns {Promise<{
115
187
  * totalLearnings: number,
116
188
  * activeLearnings: number,
117
189
  * eligibleCount: number,
190
+ * alreadyMaterialized: number,
191
+ * backlogCount: number,
118
192
  * lastRunAt: string|null,
119
193
  * lastRunCandidateCount: number,
120
194
  * delta: number,
@@ -131,6 +205,8 @@ export async function computeReconcileNudge(opts = {}) {
131
205
  totalLearnings: 0,
132
206
  activeLearnings: 0,
133
207
  eligibleCount: 0,
208
+ alreadyMaterialized: 0,
209
+ backlogCount: 0,
134
210
  lastRunAt: null,
135
211
  lastRunCandidateCount: 0,
136
212
  delta: 0,
@@ -166,20 +242,29 @@ export async function computeReconcileNudge(opts = {}) {
166
242
 
167
243
  if (active.length === 0) return empty;
168
244
 
169
- let eligibleCount;
170
- try {
171
- eligibleCount = filterEligible(entries).eligible.length;
172
- } catch {
173
- eligibleCount = 0;
174
- }
245
+ // Same partition a real /reconcile run applies (engine.mjs step 3/3a) — one
246
+ // truth, so the nudge can only fire on work /reconcile is able to clear.
247
+ // That includes the placeholder-insight gate: when the caller did not supply
248
+ // `minInsightChars`, read it from Session Config BEFORE counting, or the
249
+ // banner counts learnings `/reconcile` would reject.
250
+ const minInsightChars = Number.isFinite(opts.minInsightChars)
251
+ ? Number(opts.minInsightChars)
252
+ : (await _resolveReconcileSettings(repoRoot, opts.config)).minInsightChars;
175
253
 
254
+ // The candidate store is read ONCE, BEFORE the backlog count — the count needs
255
+ // the same records for its sidecar-dedupe half, and reading them here lets it
256
+ // be handed down instead of read a second time (`candidatesRead` says whether
257
+ // the hand-down is trustworthy; on a failed read we hand nothing down and let
258
+ // `countReconcileBacklog` decide for itself rather than pass a fake empty set).
176
259
  /** @type {Array<Record<string, unknown>>} */
177
260
  let candidates;
178
261
  /** @type {number|undefined} */
179
262
  let skippedCandidates;
263
+ let candidatesRead = false;
180
264
  try {
181
265
  const diag = loadCandidates({ repoRoot });
182
266
  candidates = Array.isArray(diag?.records) ? diag.records : [];
267
+ candidatesRead = Array.isArray(diag?.records);
183
268
  // Absence-preserving, mirroring `engine.mjs` `summary.skipped`: only a
184
269
  // finite count means "the store was inspected". Absent ⇒ never checked,
185
270
  // 0 ⇒ checked and clean.
@@ -190,6 +275,25 @@ export async function computeReconcileNudge(opts = {}) {
190
275
  // fabricating a clean 0.
191
276
  }
192
277
 
278
+ // ONE corpus, ONE population. `entries` (read above via `readLearnings`) is
279
+ // handed down instead of letting the count re-read and re-normalize the same
280
+ // file: the banner line names `activeLearnings` and `backlogCount` side by
281
+ // side, and two independent reads of one file are two populations wearing one
282
+ // sentence (HR-106). `repoRoot` stays required — the sidecar and the
283
+ // `.claude/rules/` provenance scan are resolved from it.
284
+ // Never throws (all-zero on failure).
285
+ const {
286
+ eligible: eligibleCount,
287
+ alreadyMaterialized,
288
+ backlog: backlogCount,
289
+ } = countReconcileBacklog({
290
+ repoRoot,
291
+ learnings: entries,
292
+ ...(candidatesRead ? { existingCandidates: candidates } : {}),
293
+ now: opts.now,
294
+ minInsightChars,
295
+ });
296
+
193
297
  const lastRunAt = _lastRunAt(candidates);
194
298
  const lastRunCandidateCount = Array.isArray(candidates) ? candidates.length : 0;
195
299
  const delta = entries.length - lastRunCandidateCount;
@@ -213,15 +317,18 @@ export async function computeReconcileNudge(opts = {}) {
213
317
  if (lastRunAt !== null && delta > NUDGE_MIN_DELTA) {
214
318
  reasons.push(`${delta} new learnings since the last reconcile run`);
215
319
  }
216
- // (c) — enough rule-eligible learnings to be worth a batch, independent of confidence.
217
- if (eligibleCount >= NUDGE_MIN_ELIGIBLE) {
218
- reasons.push(`${eligibleCount} rule-eligible learnings`);
320
+ // (c) — enough NOT-YET-MATERIALIZED rule-eligible learnings to be worth a
321
+ // batch, independent of confidence. HR-106: the reason names the number judged.
322
+ if (backlogCount >= NUDGE_MIN_ELIGIBLE) {
323
+ reasons.push(`${backlogCount} rule-eligible learnings awaiting a rule`);
219
324
  }
220
325
 
221
326
  const computed = {
222
327
  totalLearnings: entries.length,
223
328
  activeLearnings: active.length,
224
329
  eligibleCount,
330
+ alreadyMaterialized,
331
+ backlogCount,
225
332
  lastRunAt,
226
333
  lastRunCandidateCount,
227
334
  delta,
@@ -236,25 +343,6 @@ export async function computeReconcileNudge(opts = {}) {
236
343
  return computed;
237
344
  }
238
345
 
239
- /**
240
- * Best-effort extraction of `reconcile.enabled` from an already-parsed Session
241
- * Config object (DI/test seam — avoids re-reading CLAUDE.md when the caller
242
- * already has it). Returns `null` when unresolvable from `config` alone, in
243
- * which case `checkReconcileNudge` falls back to reading CLAUDE.md/AGENTS.md.
244
- *
245
- * @param {unknown} config
246
- * @returns {boolean|null}
247
- */
248
- function _reconcileEnabledFromConfig(config) {
249
- if (config && typeof config === 'object') {
250
- const rc = /** @type {Record<string, unknown>} */ (config).reconcile;
251
- if (rc && typeof rc === 'object' && typeof (/** @type {any} */ (rc).enabled) === 'boolean') {
252
- return /** @type {any} */ (rc).enabled;
253
- }
254
- }
255
- return null;
256
- }
257
-
258
346
  /**
259
347
  * Check reconcile-nudge readiness and produce a session-start banner.
260
348
  *
@@ -274,26 +362,30 @@ export async function checkReconcileNudge(opts = {}) {
274
362
  const { repoRoot, config, now } = opts;
275
363
  if (!repoRoot || typeof repoRoot !== 'string') return null;
276
364
 
365
+ // ONE config read for both settings, BEFORE the backlog is counted — the
366
+ // `min-insight-chars` gate must reach `countReconcileBacklog`, and reading the
367
+ // config afterwards (as this did before) left it inert.
368
+ let reconcileEnabled = false;
369
+ let minInsightChars;
370
+ try {
371
+ ({ enabled: reconcileEnabled, minInsightChars } = await _resolveReconcileSettings(
372
+ repoRoot,
373
+ config,
374
+ ));
375
+ } catch {
376
+ // Unreachable in practice (_resolveReconcileSettings never throws) — keep the
377
+ // conservative default and let computeReconcileNudge resolve the gate itself.
378
+ reconcileEnabled = false;
379
+ }
380
+
277
381
  let computed;
278
382
  try {
279
- computed = await computeReconcileNudge({ repoRoot, now });
383
+ computed = await computeReconcileNudge({ repoRoot, now, minInsightChars });
280
384
  } catch {
281
385
  return null;
282
386
  }
283
387
  if (!computed || computed.nudge !== true) return null;
284
388
 
285
- let reconcileEnabled = _reconcileEnabledFromConfig(config);
286
- if (reconcileEnabled === null) {
287
- try {
288
- const content = await readConfigFile(repoRoot);
289
- reconcileEnabled = _parseReconcile(content).enabled;
290
- } catch {
291
- // Config unreadable — fall back to the documented config default (false)
292
- // so the informational parenthetical still renders conservatively.
293
- reconcileEnabled = false;
294
- }
295
- }
296
-
297
389
  // Three-state last-run label. `never` is a claim about history and must only
298
390
  // be made when the store was inspected and held nothing: a store whose lines
299
391
  // were quarantined by the shape guard is EVIDENCE OF A RUN that can no longer
@@ -323,7 +415,8 @@ export async function checkReconcileNudge(opts = {}) {
323
415
  }
324
416
 
325
417
  const lines = [
326
- `⚠ reconcile-nudge: ${computed.activeLearnings} active learnings, ${computed.eligibleCount} rule-eligible, ` +
418
+ `⚠ reconcile-nudge: ${computed.activeLearnings} active learnings, ${computed.backlogCount} rule-eligible awaiting a rule ` +
419
+ `(${computed.alreadyMaterialized} already materialized), ` +
327
420
  `last reconcile run: ${lastRunLabel} — run /reconcile to convert learnings into rules.`,
328
421
  ];
329
422
  if (reconcileEnabled === false) {
@@ -30,6 +30,37 @@ export function parseEtimeToMinutes(etime) {
30
30
  return days * 24 * 60 + hours * 60 + mins;
31
31
  }
32
32
 
33
+ /**
34
+ * Parse a `ps` etime field (`[[DD-]HH:]MM:SS`) into whole SECONDS.
35
+ *
36
+ * Twin of {@link parseEtimeToMinutes}, which drops the seconds field because
37
+ * minute resolution is all the zombie counter needs. The orphan reaper
38
+ * (`scripts/lib/orphan-reaper.mjs`) cannot use that: its minimum age is 300
39
+ * seconds and its identity check reconstructs a start time as
40
+ * `now - etimeSeconds * 1000`, where a discarded seconds field would be a
41
+ * systematic error of up to 59 s against a 2 s tolerance.
42
+ *
43
+ * Deliberately a sibling rather than a refactor of the twin: `parseEtimeToMinutes`
44
+ * has live callers (`countZombieProcesses`) whose behaviour must not shift.
45
+ *
46
+ * Returns null when the format is unrecognised. Pure function.
47
+ * @param {string} etime
48
+ * @returns {number|null}
49
+ */
50
+ export function parseEtimeToSeconds(etime) {
51
+ if (typeof etime !== 'string') return null;
52
+ const s = etime.trim();
53
+ // Same shape as parseEtimeToMinutes: optional "DD-", optional "HH:", then "MM:SS".
54
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(s);
55
+ if (!m) return null;
56
+ const days = parseInt(m[1] ?? '0', 10);
57
+ const hours = parseInt(m[2] ?? '0', 10);
58
+ const mins = parseInt(m[3], 10);
59
+ const secs = parseInt(m[4], 10);
60
+ if ([days, hours, mins, secs].some(Number.isNaN)) return null;
61
+ return days * 86400 + hours * 3600 + mins * 60 + secs;
62
+ }
63
+
33
64
  /**
34
65
  * Parse the detailed ps output and count Claude/Node zombie candidates.
35
66
  * A zombie candidate is a process matching "claude" or "node" that:
@@ -11,8 +11,9 @@
11
11
  * when at least one `scopePath` matches at least one glob pattern.
12
12
  *
13
13
  * `paths:` is a same-shape alias for `globs:` (issue #795) — some repos use a
14
- * `paths:` frontmatter convention instead of `globs:` (e.g. projects-baseline:
15
- * 26 rule files, all `paths:`, 0 `globs:`). Before #795 these repos'
14
+ * `paths:` frontmatter convention instead of `globs:` (projects-baseline did
15
+ * when #795 landed; it now carries both keys measured count:
16
+ * docs/baseline.md). Before #795 these repos'
16
17
  * path-scoped rules were silently misclassified as always-on, inflating the
17
18
  * `instruction-budget-guard.mjs` (#687) always-on count with a false positive.
18
19
  * `paths:` supports the identical inline-array and block-list forms as
@@ -365,6 +366,35 @@ function metaToEntryFields(meta) {
365
366
  return out;
366
367
  }
367
368
 
369
+ /**
370
+ * The expiry predicate, shared by the injection gate below and the `/reconcile`
371
+ * provenance reader (`scripts/lib/reconcile/backlog.mjs`) — one `Date.parse`
372
+ * for both, so "expired" can never mean two different things to the two
373
+ * consumers (#1387).
374
+ *
375
+ * FAIL-OPEN by design: an absent, empty or unparseable `expires-at` is NOT
376
+ * expired. Fail-closed here would un-inject (and, in the reconcile reader,
377
+ * un-materialize) the whole corpus on a single typo.
378
+ *
379
+ * Pure apart from the optional `onUnparseable` callback, which exists so the
380
+ * injection path can keep its stderr WARN while the reconcile reader stays
381
+ * silent.
382
+ *
383
+ * @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
384
+ * @param {number} [now] - epoch ms; defaults to `Date.now()`
385
+ * @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
386
+ * @returns {boolean} true only when a PARSEABLE `expires-at` lies before `now`
387
+ */
388
+ export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
389
+ if (rawExpiry === undefined || rawExpiry === null) return false;
390
+ const ts = Date.parse(String(rawExpiry));
391
+ if (Number.isNaN(ts)) {
392
+ if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
393
+ return false;
394
+ }
395
+ return ts < now;
396
+ }
397
+
368
398
  /**
369
399
  * Deterministic gating check (issue #694 + #692). Returns `{ excluded: boolean,
370
400
  * reason?: string }`. Applied to BOTH always-on and glob-matched rules.
@@ -378,18 +408,17 @@ function metaToEntryFields(meta) {
378
408
  * @returns {{ excluded: boolean }}
379
409
  */
380
410
  function applyGates(meta, filePath, mode, hostClass, now, context = null) {
381
- // Expiry gate — fail-open on a malformed `expires-at`.
411
+ // Expiry gate — fail-open on a malformed `expires-at` ({@link isRuleExpired}).
382
412
  if (Object.prototype.hasOwnProperty.call(meta, 'expires-at')) {
383
413
  const rawExpiry = meta['expires-at'];
384
- const ts = Date.parse(String(rawExpiry));
385
- if (Number.isNaN(ts)) {
386
- process.stderr.write(
387
- `[rule-loader] Rule ${filePath} has unparseable expires-at ${JSON.stringify(rawExpiry)} — ignoring expiry\n`,
388
- );
389
- } else if (ts < now) {
390
- process.stderr.write(
391
- `[rule-loader] Rule ${filePath} expired at ${rawExpiry} — excluded\n`,
392
- );
414
+ if (
415
+ isRuleExpired(rawExpiry, now, (raw) =>
416
+ process.stderr.write(
417
+ `[rule-loader] Rule ${filePath} has unparseable expires-at ${JSON.stringify(raw)} — ignoring expiry\n`,
418
+ ),
419
+ )
420
+ ) {
421
+ process.stderr.write(`[rule-loader] Rule ${filePath} expired at ${rawExpiry} — excluded\n`);
393
422
  return { excluded: true };
394
423
  }
395
424
  }
@@ -21,6 +21,13 @@
21
21
  * when the platform exposes a stable prompt-assembly boundary; at that point the
22
22
  * digest can be computed against the real assembled prompt instead of echoed.
23
23
  *
24
+ * CALLERS (#1298): the CLI modes run only from prose — `--instruction` in
25
+ * `skills/wave-executor/references/wave-loop-dispatch.md`, `--emit` and `--verify`
26
+ * in `wave-loop-review.md`. Ceiling: `check-unwired-features` S4 cannot notice both
27
+ * dropping that citation — it judges modules, not CLI modes, and reads this one wired
28
+ * as a CLI entrypoint AND via its hook importer (`scopeDigest`). Revisit on a third
29
+ * prose caller, or when the census learns to track prose-invoked CLI entrypoints.
30
+ *
24
31
  * Pure + stdlib only. Nothing here throws on malformed input — a broken echo
25
32
  * check must never change a wave's outcome.
26
33
  *
@@ -34,6 +41,8 @@
34
41
  * node scripts/lib/scope-echo.mjs --scope-file <path> --instruction
35
42
  * node scripts/lib/scope-echo.mjs --scope-file <path> --report-file <path> \
36
43
  * [--wave N --agent-id ID --emit]
44
+ * node scripts/lib/scope-echo.mjs --verify --wave <N> --state-dir <dir> \
45
+ * [--session <id> --events <path> --json --emit]
37
46
  * node scripts/lib/scope-echo.mjs --help
38
47
  */
39
48
 
@@ -312,13 +321,22 @@ function parseJsonl(raw) {
312
321
  * Digest every per-agent scope file of one wave — shape (a) of the two scope
313
322
  * shapes (CLAUDE.md / AGENTS.md § allowedPaths).
314
323
  *
324
+ * A file that cannot be read or parsed is the FILE-side counterpart of a
325
+ * truncated ledger line: skipping it silently makes a wave whose scope files are
326
+ * corrupt indistinguishable from one that was never injected (`digest-unknown` /
327
+ * `injection-missing`). The count therefore rides back on the returned Map as
328
+ * `malformedFiles` — an additive own property, so every existing consumer that
329
+ * only iterates the entries is unaffected. (A file that parses to a non-array or
330
+ * to an empty scope stays a silent skip: an empty scope is legitimate.)
331
+ *
315
332
  * @param {string} stateDir
316
333
  * @param {number} wave
317
334
  * @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
318
- * @returns {Map<string, string[]>} digest → agent ids (file basenames)
335
+ * @returns {Map<string, string[]> & {malformedFiles: number}} digest → agent ids (file basenames)
319
336
  */
320
337
  export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFile = readFileSync } = {}) {
321
338
  const out = new Map();
339
+ out.malformedFiles = 0;
322
340
  const dir = resolve(String(stateDir), 'filescopes', `wave-${wave}`);
323
341
  let names;
324
342
  try {
@@ -332,7 +350,10 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
332
350
  let parsed;
333
351
  try {
334
352
  parsed = JSON.parse(readFile(join(dir, file), 'utf8'));
335
- } catch { continue; }
353
+ } catch {
354
+ out.malformedFiles += 1; // unreadable or unparseable — not nothing
355
+ continue;
356
+ }
336
357
  if (!Array.isArray(parsed) || normalizeScopePaths(parsed).length === 0) continue;
337
358
  const digest = scopeDigest(parsed);
338
359
  const ids = out.get(digest) ?? [];
@@ -372,6 +393,12 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
372
393
  * evidence — the counts and verdicts below are a floor, not a census — and the
373
394
  * human table says so beside them.
374
395
  *
396
+ * `malformed_scope_files` is the same measurement on the FILE side (#1379 P2):
397
+ * a scope file that cannot be read or parsed was silently skipped, which made a
398
+ * corrupt wave yield `digest-unknown` / `injection-missing` verdicts
399
+ * indistinguishable from a genuinely missing injection. Always present,
400
+ * including as `0`.
401
+ *
375
402
  * ## Transport degradation
376
403
  *
377
404
  * On a platform with no `PreToolUse` `Agent` matcher (Codex, Cursor, Pi) the
@@ -491,6 +518,7 @@ export function verifyWaveScope({
491
518
  injected,
492
519
  echoed,
493
520
  malformed_lines: malformedLines,
521
+ malformed_scope_files: fileIds.malformedFiles ?? 0,
494
522
  by_verdict: byVerdict,
495
523
  agents,
496
524
  };
@@ -506,6 +534,8 @@ export function verifyWaveScope({
506
534
  * including as `0` — it is the denominator's honesty check: without it a join
507
535
  * that silently dropped half the ledger is indistinguishable in the record from
508
536
  * a wave where nothing went wrong (`.claude/rules/host-resources.md` § HR-105).
537
+ * `malformed_scope_files` carries the same guarantee for the scope files the
538
+ * join reads (#1379 P2).
509
539
  *
510
540
  * @param {ReturnType<typeof verifyWaveScope>} report
511
541
  * @returns {Record<string, unknown>}
@@ -518,6 +548,7 @@ export function scopeVerifiedPayload(report) {
518
548
  injected: report.injected,
519
549
  echoed: report.echoed,
520
550
  malformed_lines: report.malformed_lines ?? 0,
551
+ malformed_scope_files: report.malformed_scope_files ?? 0,
521
552
  by_verdict: report.by_verdict,
522
553
  digests: report.agents.map((a) => a.digest),
523
554
  };
@@ -636,6 +667,12 @@ async function mainVerify(args) {
636
667
  ? [` WARNING: ${report.malformed_lines} malformed ledger line(s) skipped — `
637
668
  + 'counts and verdicts below are a floor, not a census']
638
669
  : []),
670
+ // Same honesty check on the FILE side: an unparseable scope file makes a
671
+ // corrupt wave look exactly like one that was never injected.
672
+ ...(report.malformed_scope_files > 0
673
+ ? [` WARNING: ${report.malformed_scope_files} malformed scope file(s) skipped — `
674
+ + 'digest-unknown / injection-missing below may be corruption, not a missing injection']
675
+ : []),
639
676
  ...report.agents.map((a) => ` ${a.digest} ${a.verdict.padEnd(20)} ${a.agent_id}`),
640
677
  ];
641
678
  process.stdout.write(`${lines.join('\n')}\n`);