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
@@ -37,6 +37,15 @@
37
37
  * 1. **Unresolvable id → the entry is KEPT** and counted in
38
38
  * `unresolvedPairIds`. A sweep that guessed would delete prose on no
39
39
  * evidence, and a file with any unresolved pair is never deleted.
40
+ * 1b. **Unrecognised counter sentence → `action: 'keep'` + a `skipped`
41
+ * record (`no-counter-sentence`), and NO write of any kind** — not a
42
+ * rewrite, not a raise, not a delete. Every write here moves the
43
+ * frontmatter `expires-at` and the body sentence that restates it; when
44
+ * only the header moves, the file ships a header contradicting a body
45
+ * sentence that forbids exactly that correction (GH#70, reported from a
46
+ * German-language consumer corpus 2026-09-20). Recognising a second
47
+ * spelling ({@link COUNTER_FORMS}) is the narrow half of that fix; the
48
+ * refusal is the half that holds for the third language too.
40
49
  * 2. **Ambiguous file → `action: 'keep'` + a `skipped` record.** The
41
50
  * entry↔pair mapping is POSITIONAL: the k-th `### ` heading belongs to the
42
51
  * k-th non-`markers only` pair. That 1:1 mapping holds in only 3 of the 7
@@ -48,28 +57,54 @@
48
57
  * `no-1to1-mapping` and touches nothing. It never guesses which paragraph
49
58
  * belongs to which learning.
50
59
  *
51
- * ## Header recompute happens ONLY on a file this sweep rewrites
60
+ * ## Header recompute is ONE-DIRECTIONAL: raised, never lowered
52
61
  *
53
62
  * A consolidated file's frontmatter `expires-at` plus the body sentence
54
63
  * ``**`expires-at` <D> = the EARLIEST of the <N> absorbed dates**`` must not
55
- * outlive its shortest-lived content. Both are recomputed but only for a file
56
- * the sweep actually rewrites. A file with nothing expired is left
57
- * BYTE-IDENTICAL, deliberately, because a recompute-on-every-run would silently
58
- * shorten a healthy file's TTL on the very first live invocation. The
59
- * discrepancy is REPORTED instead, as the per-plan `advisory` field see
60
- * {@link headerAdvisory}, which is computed BEFORE the `no-1to1-mapping` skip
61
- * so a skipped file still gets one.
62
- *
63
- * Measured 2026-09-17 (`node scripts/sweep-expired-rules.mjs --json`, 7 files
64
- * scanned, 0 expired): **6 of the 7 carry a discrepancy** — `identity-and-locks`
65
- * 2026-10-01 vs 2026-10-02, `measurement-discipline` 2026-10-04 vs 2026-10-02,
66
- * `process-contracts` 2026-10-04 vs 2026-10-27, `review-and-adapter-contracts`
67
- * 2026-10-04 vs 2026-10-02, `test-hygiene` 2026-10-20 vs 2026-10-07,
68
- * `toolchain-and-build` 2026-10-01 vs 2026-10-16; only `guard-design` agrees
69
- * with its content. Three of the six are the harmful direction — a header
70
- * OUTLIVING its content: `measurement-discipline`, `review-and-adapter-contracts`
71
- * and `test-hygiene`. The other three expire EARLIER than they need to, which
72
- * costs injection but loses nothing.
64
+ * outlive its shortest-lived content. The two directions of a
65
+ * header-vs-content discrepancy are NOT symmetric, so they are handled
66
+ * differently:
67
+ *
68
+ * - **Header EARLIER than the earliest absorbed date RAISED, and that raise
69
+ * is itself a rewrite trigger** (`action: 'rewrite'`,
70
+ * `reason: 'header-raise'`). Left alone, the cliff has no way back:
71
+ * `scripts/lib/rule-loader.mjs` drops a file whose header has passed out of
72
+ * injection even though not one absorbed entry expired, while the surviving
73
+ * `learning-key`/`learning-id` markers keep `/reconcile` treating those
74
+ * learnings as materialized so the substance goes dark silently and is
75
+ * never re-proposed. Measured 2026-09-18 (`--json`, live corpus): 3 of 7
76
+ * files are in that state (`identity-and-locks` 2026-10-01 vs 2026-10-02,
77
+ * `process-contracts` 2026-10-04 vs 2026-10-27, `toolchain-and-build`
78
+ * 2026-10-01 vs 2026-10-16), i.e. 31.5 kB of corpus that would fall dark
79
+ * with nothing expired.
80
+ * - **Header LATER than the earliest absorbed date NEVER lowered**, only
81
+ * reported via the per-plan `advisory` field. Lowering on every run would
82
+ * shorten a healthy file's TTL — killing an entry early — on the very first
83
+ * live invocation, which is the direction that destroys substance rather
84
+ * than preserving it.
85
+ *
86
+ * The raise is MINIMAL: exactly the frontmatter `expires-at:` line and the
87
+ * `COUNTER_RE` body sentence change; every other byte of the file is left
88
+ * untouched (see {@link rewriteHeaderOnly}). It needs no entry↔pair mapping —
89
+ * it compares the frontmatter date against dates read out of `learnings.jsonl`
90
+ * by id — so a `no-1to1-mapping` file gets it too, exactly like the advisory
91
+ * (see {@link headerAdvisory}); that file's `skipped` record refers to the
92
+ * PROSE sweep, which still touches nothing there.
93
+ *
94
+ * `reason` rather than a novel `action` value, deliberately: the CLI
95
+ * (`scripts/sweep-expired-rules.mjs`) aggregates its human summary line and its
96
+ * `applied` counters over the three actions `rewrite`/`delete`/`keep` only, so a
97
+ * fourth action name would make a header raise invisible in exactly the preview
98
+ * an operator reads before `--apply`. A raise IS a write; it is counted as one.
99
+ *
100
+ * Measured 2026-09-18 (`node scripts/sweep-expired-rules.mjs --json`, 7 files
101
+ * scanned, 0 expired, 0 skipped): **3 of the 7 carry a discrepancy**, all three
102
+ * in the header-EARLIER direction and therefore all three raises —
103
+ * `identity-and-locks` 2026-10-01 → 2026-10-02, `process-contracts` 2026-10-04
104
+ * → 2026-10-27, `toolchain-and-build` 2026-10-01 → 2026-10-16. The other four
105
+ * agree with their content. (An earlier reading on 2026-09-17 listed six
106
+ * discrepancies including three header-LATER ones; the corpus has been
107
+ * hand-consolidated since, so re-measure rather than trusting either list.)
73
108
  *
74
109
  * Before the advisory moved above the skip branch it reached only the 3
75
110
  * 1:1-mappable files, which structurally excluded the four merged-prose files —
@@ -134,11 +169,77 @@ const MS_PER_DAY = 86_400_000;
134
169
 
135
170
  const PAIR_KEY_RE = /^- learning-key:\s*`([^`]+)`/;
136
171
  const PAIR_ID_RE = /^- learning-id:\s*`([^`]+)`(.*)$/;
137
- const COUNTER_RE = /^\*\*`expires-at` (\S+) = the EARLIEST of the (\d+) absorbed dates\*\*/;
172
+
173
+ /**
174
+ * The recognised spellings of the body counter sentence, each with the date as
175
+ * capture 1 and the absorbed-pair count as capture 2, in that order.
176
+ *
177
+ * The `d` flag is load-bearing: {@link spliceCounterLine} rewrites the line by
178
+ * replacing exactly the two captured SPANS, so every other byte — the wording,
179
+ * the language, any trailing clause — survives a rewrite untouched. That is why
180
+ * a second language costs one entry here and no re-templating anywhere: a
181
+ * template would silently translate a German sentence into English on the first
182
+ * header raise.
183
+ *
184
+ * English is this repo's own form (all 7 live consolidated files, measured
185
+ * 2026-09-20 @ 9b118cf6: `grep -n 'EARLIEST of the' .claude/rules/*.md` → 7
186
+ * hits, `grep -rn 'FRUEHESTE\|FRÜHESTE' .claude/rules/` → 0). German is the
187
+ * consumer-side form reported from EventDrop.at (GH#70, 2026-09-20), where a
188
+ * raise lifted the frontmatter and left the body sentence claiming the older
189
+ * date — the sentence that literally forbids correcting it upward.
190
+ *
191
+ * @type {readonly RegExp[]}
192
+ */
193
+ const COUNTER_FORMS = Object.freeze([
194
+ /^\*\*`expires-at` (\S+) = the EARLIEST of the (\d+) absorbed dates\*\*/d,
195
+ /^\*\*`expires-at` (\S+) = das FR(?:UE|Ü)HESTE der (\d+) aufgenommenen Daten\*\*/d,
196
+ ]);
197
+
138
198
  const HEADING_PREFIX = '### ';
139
199
  const PROVENANCE_HEADING = '## Provenance';
140
200
  const UNTRUSTED_END = '<!-- untrusted-content:end -->';
141
201
 
202
+ /**
203
+ * Match one line against every recognised counter-sentence form.
204
+ *
205
+ * @param {string} line
206
+ * @returns {{date: string, count: number, match: RegExpExecArray}|null}
207
+ */
208
+ function matchCounterLine(line) {
209
+ for (const form of COUNTER_FORMS) {
210
+ const m = form.exec(line);
211
+ if (m) return { date: m[1], count: Number(m[2]), match: m };
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Rewrite a counter-sentence line to a new date and count by splicing the two
218
+ * captured spans, leaving every other byte — wording, language, trailing text —
219
+ * exactly as authored.
220
+ *
221
+ * Splices BACK-TO-FRONT (count before date) so the earlier capture's indices
222
+ * stay valid while the later text is replaced.
223
+ *
224
+ * @param {string} line the line as it stands in the file
225
+ * @param {string} date `YYYY-MM-DD` to write
226
+ * @param {number} count the absorbed-pair count to write
227
+ * @returns {string} the rewritten line, or `line` unchanged when no form matches
228
+ */
229
+ function spliceCounterLine(line, date, count) {
230
+ const hit = matchCounterLine(line);
231
+ if (!hit) return line;
232
+ const [dateStart, dateEnd] = hit.match.indices[1];
233
+ const [countStart, countEnd] = hit.match.indices[2];
234
+ return (
235
+ line.slice(0, dateStart) +
236
+ date +
237
+ line.slice(dateEnd, countStart) +
238
+ String(count) +
239
+ line.slice(countEnd)
240
+ );
241
+ }
242
+
142
243
  /**
143
244
  * Parse a consolidated machine-generated rule file into its editable parts.
144
245
  *
@@ -193,11 +294,11 @@ export function parseConsolidatedRule(content) {
193
294
  const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
194
295
  for (let i = bodyStart; i < lines.length; i += 1) {
195
296
  if (counterLine === -1) {
196
- const m = COUNTER_RE.exec(lines[i]);
197
- if (m) {
297
+ const hit = matchCounterLine(lines[i]);
298
+ if (hit) {
198
299
  counterLine = i;
199
- counterDate = m[1];
200
- counterCount = Number(m[2]);
300
+ counterDate = hit.date;
301
+ counterCount = hit.count;
201
302
  continue;
202
303
  }
203
304
  }
@@ -292,6 +393,51 @@ function dateOnly(iso) {
292
393
  return String(iso).slice(0, 10);
293
394
  }
294
395
 
396
+ /**
397
+ * The earliest resolvable absorbed date on the file, as `YYYY-MM-DD`.
398
+ *
399
+ * Population: EVERY provenance pair whose `learning-id` resolves to a parseable
400
+ * `expires_at`, markers-only pairs included — an absorbed date stays absorbed
401
+ * after its prose is gone, which is exactly what the body sentence's `N` counts.
402
+ * Unresolvable ids contribute nothing (they are reported separately as
403
+ * `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
404
+ * rather than a guess.
405
+ *
406
+ * @param {ReturnType<typeof parseConsolidatedRule>} parsed
407
+ * @param {Map<string, string>} expiryById
408
+ * @returns {string|null}
409
+ */
410
+ function earliestResolvableDate(parsed, expiryById) {
411
+ const dates = parsed.pairs
412
+ .map((p) => expiryById.get(p.id))
413
+ .filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
414
+ .sort();
415
+ return dates.length > 0 ? dateOnly(dates[0]) : null;
416
+ }
417
+
418
+ /**
419
+ * The date to RAISE a too-low header to, or `null` when there is nothing to do.
420
+ *
421
+ * One-directional by contract: returns a date only when the header expires
422
+ * STRICTLY EARLIER than the earliest resolvable absorbed date. A header that
423
+ * outlives its content is never lowered here — see the module header for why
424
+ * the two directions are not symmetric.
425
+ *
426
+ * @param {ReturnType<typeof parseConsolidatedRule>} parsed
427
+ * @param {Map<string, string>} expiryById
428
+ * @returns {string|null}
429
+ */
430
+ function headerRaiseTarget(parsed, expiryById) {
431
+ if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
432
+ const headerMs = Date.parse(parsed.expiresAt);
433
+ if (!Number.isFinite(headerMs)) return null;
434
+ const earliest = earliestResolvableDate(parsed, expiryById);
435
+ if (earliest === null) return null;
436
+ const earliestMs = Date.parse(earliest);
437
+ if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
438
+ return earliest;
439
+ }
440
+
295
441
  /**
296
442
  * The header-vs-content discrepancy, as a one-line advisory string.
297
443
  *
@@ -312,16 +458,71 @@ function dateOnly(iso) {
312
458
  */
313
459
  function headerAdvisory(parsed, expiryById) {
314
460
  if (!parsed.expiresAt) return undefined;
315
- const dates = parsed.pairs
316
- .map((p) => expiryById.get(p.id))
317
- .filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
318
- .sort();
319
- if (dates.length === 0) return undefined;
320
- const earliest = dateOnly(dates[0]);
321
- if (earliest === parsed.expiresAt) return undefined;
461
+ const earliest = earliestResolvableDate(parsed, expiryById);
462
+ if (earliest === null || earliest === parsed.expiresAt) return undefined;
322
463
  return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
323
464
  }
324
465
 
466
+ /**
467
+ * Rewrite ONLY the two dated header lines — the frontmatter `expires-at:` and
468
+ * the `COUNTER_RE` body sentence. Every other byte of the file is carried over
469
+ * untouched; that byte-identity is the whole safety argument for making a raise
470
+ * a rewrite trigger, and it is pinned by a test.
471
+ *
472
+ * @param {ReturnType<typeof parseConsolidatedRule>} parsed
473
+ * @param {string} target `YYYY-MM-DD` to raise the header to
474
+ * @returns {string}
475
+ */
476
+ function rewriteHeaderOnly(parsed, target) {
477
+ const lines = [...parsed.lines];
478
+ if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${target}`;
479
+ if (parsed.counterLine >= 0) {
480
+ lines[parsed.counterLine] = spliceCounterLine(
481
+ lines[parsed.counterLine],
482
+ target,
483
+ parsed.pairs.length,
484
+ );
485
+ }
486
+ return lines.join('\n');
487
+ }
488
+
489
+ /**
490
+ * The plan for a file with NO expired entry: a byte-identical `keep`, or — when
491
+ * the header sits below the earliest absorbed date — a minimal `header-raise`
492
+ * rewrite. Shared by both no-expiry branches (the `no-1to1-mapping` skip and
493
+ * the ordinary nothing-expired case) because the raise needs no entry↔pair
494
+ * mapping.
495
+ *
496
+ * @param {object} base the common plan fields
497
+ * @param {ReturnType<typeof parseConsolidatedRule>} parsed
498
+ * @param {string|undefined} advisory
499
+ * @param {string|null} raiseTo
500
+ * @returns {object}
501
+ */
502
+ function keepOrRaisePlan(base, parsed, advisory, raiseTo) {
503
+ if (raiseTo === null) {
504
+ return {
505
+ ...base,
506
+ action: 'keep',
507
+ newExpiresAt: null,
508
+ newAbsorbedCount: parsed.pairs.length,
509
+ bytesAfter: base.bytesBefore,
510
+ ...(advisory ? { advisory } : {}),
511
+ };
512
+ }
513
+ const nextContent = rewriteHeaderOnly(parsed, raiseTo);
514
+ return {
515
+ ...base,
516
+ action: 'rewrite',
517
+ reason: 'header-raise',
518
+ newExpiresAt: raiseTo,
519
+ newAbsorbedCount: parsed.pairs.length,
520
+ bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
521
+ nextContent,
522
+ ...(advisory ? { advisory } : {}),
523
+ };
524
+ }
525
+
325
526
  /**
326
527
  * Plan the sweep over every machine-generated rule file. Reads only.
327
528
  *
@@ -387,25 +588,65 @@ export async function planRuleExpirySweep(opts = {}) {
387
588
  // never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
388
589
  // where 6 of 7 have the discrepancy.
389
590
  const advisory = headerAdvisory(parsed, expiryById);
591
+ const raiseTo = headerRaiseTarget(parsed, expiryById);
592
+
593
+ if (parsed.counterLine === -1) {
594
+ // FAIL-CLOSED (GH#70). Every write this module performs moves the
595
+ // frontmatter `expires-at` AND the body sentence that restates it. When
596
+ // the sentence is in a spelling {@link COUNTER_FORMS} does not recognise,
597
+ // the header still moves and the sentence does not — and that sentence
598
+ // literally says the date must not be corrected upward, so the file ships
599
+ // a header contradicting its own body to every agent that loads it
600
+ // (measured on a consumer's corpus: header 2026-11-01 beside a body
601
+ // claiming 2026-10-19). A second regex alone only moves the defect to the
602
+ // next language; refusing to write is what holds for ALL of them.
603
+ //
604
+ // The refusal covers `delete` too, not only the two rewrite triggers: the
605
+ // counter sentence is part of the consolidated-rule contract
606
+ // (`docs/rule-authoring.md` § Consolidated rules point 2), so a file
607
+ // without a recognised one is a shape this sweep does not understand, and
608
+ // `delete` is its most destructive action. Report it, touch nothing.
609
+ skipped.push({ file: rule.file, reason: 'no-counter-sentence' });
610
+ plans.push(
611
+ keepOrRaisePlan(
612
+ {
613
+ file: rule.file,
614
+ expiredPairIds: [],
615
+ keptPairIds: substantive.map((p) => p.id),
616
+ unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
617
+ bytesBefore: Buffer.byteLength(content, 'utf8'),
618
+ headings: parsed.entries.length,
619
+ substantivePairs: substantive.length,
620
+ },
621
+ parsed,
622
+ advisory,
623
+ null, // never raise: the body sentence cannot be kept in agreement
624
+ ),
625
+ );
626
+ continue;
627
+ }
390
628
 
391
629
  if (parsed.entries.length !== substantive.length) {
392
- // FAIL-OPEN (decision 3): report, never guess. A skipped file still
393
- // carries its advisory reporting is all this branch can do.
630
+ // FAIL-OPEN (decision 3): report, never guess the PROSE sweep touches
631
+ // nothing here. A skipped file still carries its advisory, and still gets
632
+ // a too-low header raised: neither needs an entry↔pair mapping.
394
633
  skipped.push({ file: rule.file, reason: 'no-1to1-mapping' });
395
- plans.push({
396
- file: rule.file,
397
- action: 'keep',
398
- expiredPairIds: [],
399
- keptPairIds: substantive.map((p) => p.id),
400
- unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
401
- newExpiresAt: null,
402
- newAbsorbedCount: parsed.pairs.length,
403
- bytesBefore: Buffer.byteLength(content, 'utf8'),
404
- bytesAfter: Buffer.byteLength(content, 'utf8'),
405
- headings: parsed.entries.length,
406
- substantivePairs: substantive.length,
407
- ...(advisory ? { advisory } : {}),
408
- });
634
+ plans.push(
635
+ keepOrRaisePlan(
636
+ {
637
+ file: rule.file,
638
+ expiredPairIds: [],
639
+ keptPairIds: substantive.map((p) => p.id),
640
+ unresolvedPairIds: substantive.filter((p) => !expiryById.has(p.id)).map((p) => p.id),
641
+ bytesBefore: Buffer.byteLength(content, 'utf8'),
642
+ headings: parsed.entries.length,
643
+ substantivePairs: substantive.length,
644
+ },
645
+ parsed,
646
+ advisory,
647
+ raiseTo,
648
+ ),
649
+ );
409
650
  continue;
410
651
  }
411
652
 
@@ -443,16 +684,11 @@ export async function planRuleExpirySweep(opts = {}) {
443
684
  };
444
685
 
445
686
  if (expired.length === 0) {
446
- // Byte-identical. The header/earliest discrepancy is REPORTED, never
447
- // silently repaired see the module header.
448
- plans.push({
449
- ...base,
450
- action: 'keep',
451
- newExpiresAt: null,
452
- newAbsorbedCount: parsed.pairs.length,
453
- bytesAfter: base.bytesBefore,
454
- ...(advisory ? { advisory } : {}),
455
- });
687
+ // Byte-identical, UNLESS the header sits below the earliest absorbed date
688
+ // that one direction is repaired (raised), never merely reported, or the
689
+ // whole file falls out of injection with nothing expired. The opposite
690
+ // direction stays an advisory. See the module header.
691
+ plans.push(keepOrRaisePlan(base, parsed, advisory, raiseTo));
456
692
  continue;
457
693
  }
458
694
 
@@ -474,6 +710,7 @@ export async function planRuleExpirySweep(opts = {}) {
474
710
  plans.push({
475
711
  ...base,
476
712
  action: 'rewrite',
713
+ reason: 'expired-entries',
477
714
  newExpiresAt: earliest,
478
715
  newAbsorbedCount: parsed.pairs.length,
479
716
  bytesAfter: Buffer.byteLength(nextContent, 'utf8'),
@@ -505,9 +742,10 @@ function rewriteContent({ parsed, expired, earliest, nowMs }) {
505
742
  if (earliest !== null) {
506
743
  if (parsed.expiresAtLine >= 0) lines[parsed.expiresAtLine] = `expires-at: ${earliest}`;
507
744
  if (parsed.counterLine >= 0) {
508
- lines[parsed.counterLine] = lines[parsed.counterLine].replace(
509
- COUNTER_RE,
510
- `**\`expires-at\` ${earliest} = the EARLIEST of the ${parsed.pairs.length} absorbed dates**`,
745
+ lines[parsed.counterLine] = spliceCounterLine(
746
+ lines[parsed.counterLine],
747
+ earliest,
748
+ parsed.pairs.length,
511
749
  );
512
750
  }
513
751
  }
@@ -576,6 +814,10 @@ function assertSweepable(abs, rulesDir, file) {
576
814
  * file still dedupes) rather than an unstamped learning with no file, which
577
815
  * `/reconcile` would re-propose.
578
816
  *
817
+ * A plan carrying `reason: 'header-raise'` is an ordinary `action: 'rewrite'`
818
+ * here — same guards, same atomic write, counted in `rewritten` — because it IS
819
+ * a write to a tracked rule file; only its `nextContent` is narrower.
820
+ *
579
821
  * Every rewrite and every delete passes {@link assertSweepable} first (regular
580
822
  * file, inside the rules directory, not a symlink), and every rewrite goes
581
823
  * through `atomicWriteWithBackup` rather than `writeFileSync`. A refusal is a
@@ -198,8 +198,27 @@ export const LEARNING_KEY_RE = /^[a-z0-9/-]+$/;
198
198
  /** `expires-at:` — YYYY-MM-DD. A garbage value makes the loader's expiry gate FAIL OPEN. */
199
199
  export const EXPIRES_AT_RE = /^\d{4}-\d{2}-\d{2}$/;
200
200
 
201
- /** `learning-id` / `source-session` — rendered inside backticks, which an interior backtick would close. */
202
- export const PROVENANCE_TOKEN_RE = /^[A-Za-z0-9._:-]+$/;
201
+ /**
202
+ * `learning-id` / `source-session` — rendered inside backticks, which an
203
+ * interior backtick would close.
204
+ *
205
+ * `/` is admitted since GH#71. A semantic session id is DERIVED FROM A BRANCH
206
+ * NAME, and branch names carry slashes — so the narrower class did not reject
207
+ * hostile text, it rejected a whole legitimate id family. The damage is silent
208
+ * and unbounded: `engine.mjs` degrades the throw to an audited rejection, so
209
+ * every learning from such a session is proposed on every run and rejected on
210
+ * every run, forever. Measured in THIS repo 2026-09-20 @ `9b118cf6`:
211
+ * `jq -r 'select(.session_id != null) | .session_id'
212
+ * .orchestrator/metrics/sessions.jsonl | grep -c '/'` → **3**, all of the
213
+ * `feat/operator-surface-2026-08-08-…` family; `git branch -a --format=…
214
+ * | grep -c '/'` → **6**. It hit two usable learnings in the reporting
215
+ * consumer's run (source: peer session eventdrop-at, 2026-09-20).
216
+ *
217
+ * Widening a SANITISER is only safe against a census of what the new character
218
+ * makes expressible at the call sites — see {@link assertNoPathEscape} for the
219
+ * one hazard `/` adds and for the four it does not.
220
+ */
221
+ export const PROVENANCE_TOKEN_RE = /^[A-Za-z0-9._:/-]+$/;
203
222
 
204
223
  // The Unicode Control category (C0 + DEL + C1). Deliberately expressed as
205
224
  // `\p{Cc}` rather than a hand-written character class: an escape-written class
@@ -275,6 +294,51 @@ function rejection(field, why, value) {
275
294
  return new Error(`reconcile-sanitize: rejecting record — ${field} ${why} (got ${shown})`);
276
295
  }
277
296
 
297
+ /**
298
+ * Reject the ONE hazard a `/` adds to a machine token: a path shape.
299
+ *
300
+ * Derived from the call sites, not from caution. Both slash-bearing patterns —
301
+ * {@link PROVENANCE_TOKEN_RE} and {@link LEARNING_KEY_RE} — route through
302
+ * {@link assertMachineToken}, so the guard lives in that ONE shared function
303
+ * rather than at each caller: one guard beats one-guard-per-call-site, and it
304
+ * covers the next caller of an EXPORTED assert by construction
305
+ * (`.claude/rules/build-value.md` BV-003).
306
+ *
307
+ * **What `/` does NOT make expressible**, so this guard deliberately does not
308
+ * re-check it (each is already structurally impossible in every pattern that
309
+ * admits a slash):
310
+ *
311
+ * 1. *Inline-code break-out.* Both provenance values render inside a backtick
312
+ * span (`renderer.mjs:469-470`); a backtick is in no slash-bearing class.
313
+ * 2. *Frontmatter escape.* A newline would open a sibling top-level key — but
314
+ * JS `$` without the `m` flag does not match before a trailing newline
315
+ * (measured 2026-09-20: `/^[A-Za-z0-9._:-]+$/.test("abc\n")` → `false`),
316
+ * so a trailing newline was and stays rejected.
317
+ * 3. *Wrapper forgery.* Every literal in {@link WRAPPER_FORGERY_LITERALS}
318
+ * needs `<`, `>`, `#`, a space or `(` — none of them in these classes.
319
+ * 4. *Dangerous invisibles.* Cf code points are outside these classes too.
320
+ *
321
+ * Gated on a literal `/` being present, which makes the guard a provable no-op
322
+ * for every token shape accepted BEFORE the widening: without a separator there
323
+ * is no traversal to express, and `..` alone in a backtick span is inert text.
324
+ * Census over the live store 2026-09-20 @ `9b118cf6`: 0 of 263 learning-keys and
325
+ * 0 learning ids carry `..` or a leading `/`, so nothing legitimate is caught.
326
+ *
327
+ * @param {string} value a value that already matched its pattern
328
+ * @param {string} field
329
+ * @returns {void}
330
+ * @throws {Error} on a leading `/` (absolute path) or any `..` (traversal)
331
+ */
332
+ function assertNoPathEscape(value, field) {
333
+ if (!value.includes('/')) return;
334
+ if (value.startsWith('/')) {
335
+ throw rejection(field, 'must not start with "/" (absolute path)', value);
336
+ }
337
+ if (value.includes('..')) {
338
+ throw rejection(field, 'must not contain ".." (path traversal)', value);
339
+ }
340
+ }
341
+
278
342
  /**
279
343
  * Assert that a machine value is a non-empty string matching `pattern`.
280
344
  * REJECTS (throws) rather than repairing — see the module doc's dividing line.
@@ -282,7 +346,8 @@ function rejection(field, why, value) {
282
346
  * @param {unknown} value
283
347
  * @param {{ field: string, pattern: RegExp }} opts
284
348
  * @returns {string} the value, unchanged, when it passes
285
- * @throws {Error} when the value is not a string or does not match `pattern`
349
+ * @throws {Error} when the value is not a string, does not match `pattern`, or
350
+ * carries a path escape (see {@link assertNoPathEscape})
286
351
  */
287
352
  export function assertMachineToken(value, { field, pattern }) {
288
353
  if (typeof value !== 'string' || value === '') {
@@ -291,6 +356,7 @@ export function assertMachineToken(value, { field, pattern }) {
291
356
  if (!pattern.test(value)) {
292
357
  throw rejection(field, `must match ${pattern.source}`, value);
293
358
  }
359
+ assertNoPathEscape(value, field);
294
360
  return value;
295
361
  }
296
362