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
@@ -233,50 +233,16 @@ export const CRITERION_IDS = Object.freeze(Object.keys(CRITERIA));
233
233
  */
234
234
  export const TOTAL_WEIGHT = CRITERION_IDS.reduce((sum, id) => sum + CRITERIA[id].weight, 0);
235
235
 
236
- // ---------------------------------------------------------------------------
237
- // Die zwei harten Hürden
238
- // ---------------------------------------------------------------------------
239
-
240
- /**
241
- * Hürden sind KEINE gewichteten Kriterien. Eine gerissene Hürde ergibt Note F,
242
- * unabhängig von der Punktzahl — deshalb stehen sie getrennt und werden in
243
- * `AuqScore.hurdlesBroken` geführt, nicht in `points` verrechnet.
244
- *
245
- * @type {Readonly<Record<'H1'|'H2', Readonly<{id:string,title:string,rule:string,criterion:string,evidence:string}>>>}
246
- */
247
- export const HURDLES = Object.freeze({
248
- H1: Object.freeze({
249
- id: 'H1',
250
- title: 'Kopfzeile höchstens 12 Zeichen',
251
- rule: 'codepointLength(header) <= 12',
252
- criterion: 'K5',
253
- evidence:
254
- 'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
255
- 'Spitzenwert 54 Zeichen. Die 12 ist die Stilangabe der Tool-Beschreibung ' +
256
- '(`max 12 chars`), KEINE erzwungene Grenze: im Bundle 2.1.268 steht die Zahl nur ' +
257
- 'in ebendieser Beschreibung, es gibt kein `.max(12)` im Zod-Schema und keinen ' +
258
- 'Render-Pfad, der sie liest — 125 längere Kopfzeilen wurden vom Tool angenommen ' +
259
- 'und beantwortet (gemessen 2026-09-11). Deshalb gilt H1 nur für VORLAGEN, wo ein ' +
260
- 'Autor kostenlos kürzen kann; zur Laufzeit meldet der Hook sie und blockt nicht ' +
261
- '(siehe BLOCKING_HURDLES in hooks/pre-auq-clarity.mjs).',
262
- }),
263
- H2: Object.freeze({
264
- id: 'H2',
265
- title: '2–4 Optionen je Frage, genau eine Empfehlung auf Platz 1',
266
- rule: 'options.length zwischen 2 und 4 JE FRAGE, und isRecommended nur bei index 0',
267
- criterion: 'K6',
268
- evidence:
269
- 'Pro Block gezählt meldet skills/plan/SKILL.md:137 fälschlich 14 Optionen — ' +
270
- 'es ist ein legaler Vierfragen-Block. Der Zähler zählt je Frage.',
271
- }),
272
- });
273
-
274
- /** Stabile Reihenfolge. */
275
- export const HURDLE_IDS = Object.freeze(Object.keys(HURDLES));
276
-
277
236
  // ---------------------------------------------------------------------------
278
237
  // Schwellen — die einzige Stelle, an der eine Zahl steht
279
238
  // ---------------------------------------------------------------------------
239
+ //
240
+ // DEFINITIONSREIHENFOLGE IST TRAGEND: `HURDLES` weiter unten baut seine
241
+ // operator-sichtbaren Sätze per Template-Literal aus `THRESHOLDS`, und ein
242
+ // Template-Literal wird beim Laden des Moduls ausgewertet. Steht `THRESHOLDS`
243
+ // wieder unter `HURDLES`, wirft der Import mit `ReferenceError` — auf einem
244
+ // Modul, das `hooks/pre-auq-clarity.mjs` in JEDER Session lädt, ist das eine
245
+ // host-weite Sperre, kein Testfehler.
280
246
 
281
247
  /**
282
248
  * Alle Schwellen, nach Kriterium gruppiert. clarity.mjs liest ausschließlich
@@ -356,6 +322,55 @@ export const THRESHOLDS = Object.freeze({
356
322
  }),
357
323
  });
358
324
 
325
+ // ---------------------------------------------------------------------------
326
+ // Die zwei harten Hürden
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Hürden sind KEINE gewichteten Kriterien. Eine gerissene Hürde ergibt Note F,
331
+ * unabhängig von der Punktzahl — deshalb stehen sie getrennt und werden in
332
+ * `AuqScore.hurdlesBroken` geführt, nicht in `points` verrechnet.
333
+ *
334
+ * `title` und `rule` werden dem Operator GEDRUCKT (check-auq-clarity.mjs,
335
+ * auq-audit.mjs, der Deny-Text von hooks/pre-auq-clarity.mjs). Deshalb steht
336
+ * keine Zahl darin, sondern die Schwelle selbst: eine verschobene Schwelle mit
337
+ * einem stehengebliebenen Satz daneben ist eine Zeile, die den Operator belügt.
338
+ *
339
+ * @type {Readonly<Record<'H1'|'H2', Readonly<{id:string,title:string,rule:string,criterion:string,evidence:string}>>>}
340
+ */
341
+ export const HURDLES = Object.freeze({
342
+ H1: Object.freeze({
343
+ id: 'H1',
344
+ title: `Kopfzeile höchstens ${THRESHOLDS.K5.headerCharsFail} Zeichen`,
345
+ rule: `codepointLength(header) <= ${THRESHOLDS.K5.headerCharsFail}`,
346
+ criterion: 'K5',
347
+ // `evidence` zitiert die Tool-Beschreibung des Bundles wörtlich
348
+ // (`max 12 chars`, `.max(12)`). Diese 12 ist die Zahl DES BUNDLES, nicht
349
+ // unsere Schwelle — sie bleibt ein Literal, auch wenn K5 sich bewegt.
350
+ evidence:
351
+ 'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
352
+ 'Spitzenwert 54 Zeichen. Die 12 ist die Stilangabe der Tool-Beschreibung ' +
353
+ '(`max 12 chars`), KEINE erzwungene Grenze: im Bundle 2.1.268 steht die Zahl nur ' +
354
+ 'in ebendieser Beschreibung, es gibt kein `.max(12)` im Zod-Schema und keinen ' +
355
+ 'Render-Pfad, der sie liest — 125 längere Kopfzeilen wurden vom Tool angenommen ' +
356
+ 'und beantwortet (gemessen 2026-09-11). Deshalb gilt H1 nur für VORLAGEN, wo ein ' +
357
+ 'Autor kostenlos kürzen kann; zur Laufzeit meldet der Hook sie und blockt nicht ' +
358
+ '(siehe BLOCKING_HURDLES in hooks/pre-auq-clarity.mjs).',
359
+ }),
360
+ H2: Object.freeze({
361
+ id: 'H2',
362
+ title: `${THRESHOLDS.K6.optionsMin}–${THRESHOLDS.K6.optionsMax} Optionen je Frage, genau eine Empfehlung auf Platz 1`,
363
+ rule: `options.length zwischen ${THRESHOLDS.K6.optionsMin} und ${THRESHOLDS.K6.optionsMax} JE FRAGE, und isRecommended nur bei index 0`,
364
+ criterion: 'K6',
365
+ evidence:
366
+ 'Pro Block gezählt meldet skills/plan/SKILL.md:137 fälschlich 14 Optionen — ' +
367
+ 'es ist ein legaler Vierfragen-Block. Der Zähler zählt je Frage.',
368
+ }),
369
+ });
370
+
371
+ /** Stabile Reihenfolge. */
372
+ export const HURDLE_IDS = Object.freeze(Object.keys(HURDLES));
373
+
359
374
  /** Höchstlänge eines Zitats in einem Befund (Codepoints). */
360
375
  export const EXCERPT_MAX_CHARS = 80;
361
376
 
@@ -1,11 +1,24 @@
1
1
  /**
2
- * auto-dialectic.mjs — Cadence helper for session-end Phase 3.6.7 (#506, F2.5).
2
+ * auto-dialectic.mjs — Cadence helper for the dialectic derivation (#506, F2.5).
3
3
  *
4
- * Mirrors the auto-dream.mjs API shape exactly. Decides whether the post-session
5
- * dialectic derivation should fire, writes the proposed peer-card diff to
4
+ * Mirrors the auto-dream.mjs API shape exactly. Decides whether a dialectic
5
+ * derivation is DUE, writes the proposed peer-card diff to
6
6
  * `.orchestrator/dialectic-pending.md` atomically, and tracks the last-run
7
- * timestamp at `.orchestrator/dialectic-last-run` so the next session can
8
- * compute cadence delta.
7
+ * timestamp at `.orchestrator/dialectic-last-run`.
8
+ *
9
+ * Who calls what (the session-end Phase 3.6.7 auto-trigger is GONE — #1288):
10
+ * - `shouldDispatchAutoDialectic` — read by the session-start `maintenance-due`
11
+ * probe (`scripts/lib/maintenance-due-banner.mjs`) to report the `dialectic`
12
+ * signal. Side-effect-free by contract: a variant advancing the last-run stamp
13
+ * would consume the signal it reports.
14
+ * - `writeDialecticPending` / `consumeDialecticPending` / `writeDialecticLastRun` —
15
+ * called by `/evolve dialectic` (Step 6.4 in
16
+ * `skills/evolve/references/evolve-dialectic-mode.md`), the only trigger today.
17
+ * - `renderPendingBody` / `comparePendingBody` (#1386) — the same Step 6.4 builds
18
+ * the sidecar body with the former on the dry-run path and, on `--apply`,
19
+ * compares the freshly derived body against the reviewed sidecar with the
20
+ * latter before writing any peer card.
21
+ * - Both sidecars are READ by the same `maintenance-due` probe.
9
22
  *
10
23
  * Decision inputs (PRD F2.5 acceptance criteria):
11
24
  * - dialectic.cadence (default 5) — sessions since last dialectic run
@@ -17,7 +30,7 @@
17
30
  * separate helpers. No external deps — Node 20+ stdlib only.
18
31
  */
19
32
 
20
- import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
33
+ import { readFile, writeFile, rename, unlink, mkdir, readdir, stat, lstat } from 'node:fs/promises';
21
34
  import { existsSync } from 'node:fs';
22
35
  import { randomUUID } from 'node:crypto';
23
36
  import path from 'node:path';
@@ -37,6 +50,20 @@ export const DIALECTIC_LAST_RUN_PATH = '.orchestrator/dialectic-last-run';
37
50
  /** Repo-relative path to the pending dialectic proposal sidecar. */
38
51
  export const DIALECTIC_PENDING_PATH = '.orchestrator/dialectic-pending.md';
39
52
 
53
+ /** Repo-relative directory the consumed sidecars are archived into (#1388 P9). */
54
+ export const DIALECTIC_CONSUMED_DIR = '.orchestrator/consumed';
55
+
56
+ /**
57
+ * Retention ceiling for `.orchestrator/consumed/` — newest N archived sidecars
58
+ * survive a consume, older ones are pruned.
59
+ *
60
+ * Ceiling + revisit trigger (BV-004): newest 10; revisit if `checkStaleArtifacts`
61
+ * (`scripts/lib/project-hygiene.mjs`) ever reports a `consumed/` file. That probe
62
+ * counts every untracked `.orchestrator/**` file older than 30 days, so an
63
+ * unbounded archive would become a standing hygiene finding.
64
+ */
65
+ export const DIALECTIC_CONSUMED_RETENTION = 10;
66
+
40
67
  // ---------------------------------------------------------------------------
41
68
  // Path helpers
42
69
  // ---------------------------------------------------------------------------
@@ -49,6 +76,10 @@ function pendingPath(repoRoot) {
49
76
  return path.join(repoRoot, DIALECTIC_PENDING_PATH);
50
77
  }
51
78
 
79
+ function consumedDirPath(repoRoot) {
80
+ return path.join(repoRoot, DIALECTIC_CONSUMED_DIR);
81
+ }
82
+
52
83
  function sessionsJsonlPath(repoRoot) {
53
84
  return path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
54
85
  }
@@ -180,15 +211,16 @@ export async function readDialecticSignals({ repoRoot } = {}) {
180
211
  // ---------------------------------------------------------------------------
181
212
 
182
213
  /**
183
- * Decide whether session-end Phase 3.6.7 should dispatch
184
- * `/evolve --dialectic --dry-run`.
214
+ * Decide whether a dialectic derivation is DUE — i.e. whether the operator should
215
+ * run `/evolve dialectic` (dry-run first). Read by the session-start
216
+ * `maintenance-due` probe; no auto-trigger consumes this any more (#1288).
185
217
  *
186
218
  * Rules (PRD F2.5):
187
219
  * - cadence === 0 → never trigger (kill-switch).
188
220
  * - AC4 precondition: sessionsSinceLast === 0 AND learningsSinceLast === 0 →
189
221
  * no new input since last run → skip with reason
190
- * `no-new-input-since-last-run` (this skip MUST surface in the Final
191
- * Report verbatim as `dialectic: skipped (no new input since last run)`).
222
+ * `no-new-input-since-last-run` (the reason string is part of the contract —
223
+ * the `maintenance-due` probe reports it verbatim).
192
224
  * - sessionsSinceLast >= cadence → trigger (cadence threshold met).
193
225
  * - Otherwise → skip with reason `under-threshold (sessions=N/M)`.
194
226
  *
@@ -257,8 +289,12 @@ export async function shouldDispatchAutoDialectic({
257
289
  * never a half-written intermediate (mirrors auto-dream.mjs:248-251).
258
290
  *
259
291
  * Defensive: returns `{ok: false, error}` on filesystem failure rather than
260
- * throwing — callers (session-end Phase 3.6.7 step 7) should log the error
261
- * and continue rather than aborting the close.
292
+ * throwing — the caller logs the error and continues.
293
+ *
294
+ * Caller: `/evolve dialectic` Step 6.4 (`skills/evolve/references/evolve-dialectic-mode.md`),
295
+ * after a successful `--apply` AND after the operator explicitly discards a
296
+ * proposal. Without this write the maintenance-due `dialectic` signal never
297
+ * resets (#1380). The session-end auto-trigger that used to call it is gone.
262
298
  *
263
299
  * @param {object} args
264
300
  * @param {string} args.repoRoot
@@ -293,11 +329,17 @@ export async function writeDialecticLastRun({ repoRoot, isoTimestamp } = {}) {
293
329
  * Write the proposed dialectic diff to `.orchestrator/dialectic-pending.md`
294
330
  * atomically.
295
331
  *
332
+ * Caller: `/evolve dialectic` Step 6.4's dry-run branch
333
+ * (`skills/evolve/references/evolve-dialectic-mode.md`) — `runDialecticDeriver()`
334
+ * returns the diff and the caller persists it here. There is no session-end
335
+ * auto-trigger any more (#1288).
336
+ *
296
337
  * Caller supplies the body (a Markdown document containing the peer-card
297
338
  * diff and any narrative). This helper prepends a minimal hand-rolled YAML
298
- * frontmatter block carrying the metadata session-end's Final Report and
299
- * the next session's --apply step both rely on. The frontmatter is hand
300
- * rolled (no js-yaml dep) auto-dream pattern.
339
+ * frontmatter block carrying the metadata the operator's review and the later
340
+ * `--apply` step rely on; the session-start `maintenance-due` probe reads the
341
+ * resulting file's presence + age for its `pending-sidecar` signal. The
342
+ * frontmatter is hand rolled (no js-yaml dep) — auto-dream pattern.
301
343
  *
302
344
  * Atomicity: tmp+rename (mirrors auto-dream.mjs:248-251).
303
345
  *
@@ -366,6 +408,146 @@ export async function writeDialecticPending({
366
408
  return { path: target, bytes: Buffer.byteLength(content, 'utf8') };
367
409
  }
368
410
 
411
+ /**
412
+ * Archive file name for a consumed sidecar: `<ISO-stamp>-<8 hex>-dialectic-pending.md`.
413
+ * The stamp is `toISOString()` with `:` and `.` replaced by `-` — both are
414
+ * illegal or awkward on several filesystems, and the form still sorts
415
+ * lexicographically. The random suffix separates two consumes landing in the
416
+ * SAME millisecond — without it the second rename would overwrite the first
417
+ * archive silently.
418
+ *
419
+ * @returns {string}
420
+ */
421
+ function archiveFileName() {
422
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
423
+ return `${stamp}-${randomUUID().slice(0, 8)}-dialectic-pending.md`;
424
+ }
425
+
426
+ /**
427
+ * Exactly the names {@link archiveFileName} produces. The prune touches nothing
428
+ * else in the directory (#1390 P6): a loose suffix match would still delete an
429
+ * unrelated `operator-dialectic-pending.md`.
430
+ */
431
+ const ARCHIVE_NAME_RE =
432
+ /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}-dialectic-pending\.md$/;
433
+
434
+ /**
435
+ * Prune `.orchestrator/consumed/` to the newest `DIALECTIC_CONSUMED_RETENTION`
436
+ * archives. Only regular files whose name matches {@link ARCHIVE_NAME_RE} are
437
+ * counted or removed — anything else in the directory is not this module's to
438
+ * delete. Best-effort: every failure degrades to "pruned nothing" — an archive
439
+ * that grew one file too long is never worth failing a consume over.
440
+ *
441
+ * "Newest" is mtime DESC with the filename as tiebreaker: the archive names
442
+ * carry an ISO timestamp, which sorts lexicographically, so the two orderings
443
+ * agree except inside one millisecond.
444
+ *
445
+ * @param {string} dir Absolute path to the consumed archive directory.
446
+ * @returns {Promise<number>} Number of files removed.
447
+ */
448
+ async function pruneConsumedArchive(dir) {
449
+ let removed = 0;
450
+ try {
451
+ const entries = await readdir(dir, { withFileTypes: true });
452
+ const files = entries
453
+ .filter((e) => e.isFile() && ARCHIVE_NAME_RE.test(e.name))
454
+ .map((e) => e.name);
455
+ if (files.length <= DIALECTIC_CONSUMED_RETENTION) return 0;
456
+
457
+ const dated = [];
458
+ for (const name of files) {
459
+ let mtimeMs = 0;
460
+ try {
461
+ mtimeMs = (await stat(path.join(dir, name))).mtimeMs;
462
+ } catch {
463
+ mtimeMs = 0; // unreadable → oldest, pruned first
464
+ }
465
+ dated.push({ name, mtimeMs });
466
+ }
467
+ dated.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.name < b.name ? 1 : -1));
468
+
469
+ for (const { name } of dated.slice(DIALECTIC_CONSUMED_RETENTION)) {
470
+ try {
471
+ await unlink(path.join(dir, name));
472
+ removed += 1;
473
+ } catch {
474
+ /* best-effort */
475
+ }
476
+ }
477
+ } catch {
478
+ /* best-effort — a prune failure never fails the consume */
479
+ }
480
+ return removed;
481
+ }
482
+
483
+ /**
484
+ * Consume `.orchestrator/dialectic-pending.md` once its proposal has been
485
+ * applied or explicitly discarded: the file is MOVED into
486
+ * `.orchestrator/consumed/<ISO-timestamp>-dialectic-pending.md` rather than
487
+ * unlinked (#1388 P9 — the sidecar was the only copy of what the operator
488
+ * reviewed, and an unlink destroyed it). The maintenance-due probe reads an
489
+ * explicit two-path list (`PENDING_SIDECARS`,
490
+ * `scripts/lib/maintenance-due-banner.mjs`), so an archived file does not
491
+ * re-raise `pending-sidecar` (#1380 stays fixed).
492
+ *
493
+ * The archive is pruned to `DIALECTIC_CONSUMED_RETENTION` entries on every
494
+ * consume — see that constant for the ceiling and its revisit trigger.
495
+ *
496
+ * ENOENT is tolerated (`consumed: false`) — "already gone" is the desired end
497
+ * state. Any other filesystem error returns `{ok: false, error}` rather than
498
+ * throwing, matching `writeDialecticLastRun`.
499
+ *
500
+ * Symlink (#1390 P6, measured 2026-09-19): a pre-existing `.orchestrator/consumed`
501
+ * symlink used to be followed — `mkdir` was a no-op on it, `rename` moved the
502
+ * sidecar into its target, and the retention prune unlinked that target's
503
+ * regular files beyond the newest 10 (a tmp repro deleted 3 of 12). The consume
504
+ * now `lstat`s the directory first and REFUSES a symlink: it returns
505
+ * `{ok: false, error}`, moves nothing and prunes nothing, so the pending
506
+ * sidecar stays where it is for the caller to report. The prune additionally
507
+ * only ever removes names this module writes (see `ARCHIVE_NAME_RE`). Ceiling:
508
+ * the `lstat` → `rename` window is not atomic — a process swapping in a symlink
509
+ * inside it wins; revisit if the consumed directory ever becomes writable by a
510
+ * less-trusted uid than the operator's.
511
+ *
512
+ * @param {object} args
513
+ * @param {string} args.repoRoot
514
+ * @returns {Promise<{ok:boolean, consumed?:boolean, error?:string, path?:string, archivedTo?:string, pruned?:number}>}
515
+ */
516
+ export async function consumeDialecticPending({ repoRoot } = {}) {
517
+ if (!repoRoot) {
518
+ return { ok: false, error: 'consumeDialecticPending: repoRoot is required' };
519
+ }
520
+ const target = pendingPath(repoRoot);
521
+ if (!existsSync(target)) return { ok: true, consumed: false, path: target };
522
+
523
+ const dir = consumedDirPath(repoRoot);
524
+ const archivedTo = path.join(dir, archiveFileName());
525
+
526
+ try {
527
+ let dirStat = null;
528
+ try {
529
+ dirStat = await lstat(dir);
530
+ } catch (err) {
531
+ if (err?.code !== 'ENOENT') throw err;
532
+ }
533
+ if (dirStat?.isSymbolicLink()) {
534
+ return {
535
+ ok: false,
536
+ path: target,
537
+ error: `consumeDialecticPending: ${DIALECTIC_CONSUMED_DIR} is a symlink — refusing to archive or prune through it`,
538
+ };
539
+ }
540
+ await mkdir(dir, { recursive: true });
541
+ await rename(target, archivedTo);
542
+ } catch (err) {
543
+ if (err?.code === 'ENOENT') return { ok: true, consumed: false, path: target };
544
+ return { ok: false, error: err.message };
545
+ }
546
+
547
+ const pruned = await pruneConsumedArchive(dir);
548
+ return { ok: true, consumed: true, path: target, archivedTo, pruned };
549
+ }
550
+
369
551
  /**
370
552
  * Read `.orchestrator/dialectic-pending.md` if present. Returns the raw file
371
553
  * body (including frontmatter) so callers can decide how to parse it.
@@ -389,3 +571,110 @@ export async function readDialecticPending({ repoRoot } = {}) {
389
571
  return null;
390
572
  }
391
573
  }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // pending body — serializer + drift comparison (#1386, variant b)
577
+ // ---------------------------------------------------------------------------
578
+
579
+ /**
580
+ * Serialize a deriver diff OBJECT (`result.diff = {user?, agent?}`) into the
581
+ * Markdown body `writeDialecticPending` persists.
582
+ *
583
+ * This lived as a JS snippet in `skills/evolve/references/evolve-dialectic-mode.md`
584
+ * Step 6.4 and is moved here VERBATIM so the dry-run write and the apply-time
585
+ * comparison build the body from the same code — two hand-copied serializers
586
+ * would report drift that is only formatting (#1386).
587
+ *
588
+ * Pure: no I/O, no throw. A non-object argument, or one with neither target as
589
+ * a string, yields `''` — the caller's "nothing to review, write no sidecar"
590
+ * branch.
591
+ *
592
+ * @param {{user?: string, agent?: string}} diff
593
+ * @returns {string} Fenced Markdown body, or `''` when nothing was proposed.
594
+ */
595
+ export function renderPendingBody(diff) {
596
+ if (!diff || typeof diff !== 'object') return '';
597
+ const FENCE = '`'.repeat(3);
598
+ return ['user', 'agent']
599
+ .filter((t) => typeof diff[t] === 'string')
600
+ .map((t) => [`${FENCE}diff`, `# target: ${t}`, diff[t].trimEnd(), FENCE].join('\n'))
601
+ .join('\n\n');
602
+ }
603
+
604
+ /**
605
+ * Strip EXACTLY the leading frontmatter block `writeDialecticPending` wrote.
606
+ *
607
+ * The delimiters sit at fixed positions — line 0 and the next `---` LINE — so
608
+ * this splits on the first two delimiter LINES only. A naive `split('---')`
609
+ * breaks on a body containing a `---` line, and diff bodies do.
610
+ *
611
+ * @param {string} content
612
+ * @returns {string} The body, or the whole input when no frontmatter is present.
613
+ */
614
+ function stripPendingFrontmatter(content) {
615
+ const lines = content.split('\n');
616
+ if (lines[0] !== '---') return content;
617
+ const end = lines.indexOf('---', 1);
618
+ if (end === -1) return content; // unterminated → nothing to strip
619
+ return lines.slice(end + 1).join('\n');
620
+ }
621
+
622
+ /**
623
+ * Whitespace normalisation for the drift comparison: exactly ONE trailing
624
+ * newline is removed from each side.
625
+ *
626
+ * `writeDialecticPending` appends a newline when the body lacks one, so the
627
+ * persisted body is the rendered body plus `\n` — without this normalisation a
628
+ * clean round-trip would always report drift. Nothing else is normalised (no
629
+ * trim, no line-ending or indentation folding): anything more would hide real
630
+ * drift in a diff body, where trailing whitespace is content.
631
+ */
632
+ function normalizeTrailingNewline(text) {
633
+ return text.endsWith('\n') ? text.slice(0, -1) : text;
634
+ }
635
+
636
+ /**
637
+ * Compare a freshly derived pending body against the sidecar the operator
638
+ * reviewed (#1386, variant b).
639
+ *
640
+ * `/evolve dialectic --apply` re-derives from the model, so what gets applied
641
+ * is not necessarily what was approved in `.orchestrator/dialectic-pending.md`.
642
+ * This makes that divergence VISIBLE at apply time; it does not make apply
643
+ * deterministic.
644
+ *
645
+ * Never throws — every failure degrades to a result object, matching this
646
+ * module's other readers.
647
+ *
648
+ * @param {object} args
649
+ * @param {string} args.repoRoot
650
+ * @param {string} args.body Fresh body, typically from `renderPendingBody()`.
651
+ * @returns {Promise<{ok:boolean, drifted:boolean, sidecarAbsent:boolean, sidecarBody?:string, freshBody?:string, error?:string}>}
652
+ */
653
+ export async function comparePendingBody({ repoRoot, body } = {}) {
654
+ if (!repoRoot) {
655
+ return {
656
+ ok: false,
657
+ drifted: false,
658
+ sidecarAbsent: false,
659
+ error: 'comparePendingBody: repoRoot is required',
660
+ };
661
+ }
662
+ if (typeof body !== 'string') {
663
+ return {
664
+ ok: false,
665
+ drifted: false,
666
+ sidecarAbsent: false,
667
+ error: 'comparePendingBody: body must be a string',
668
+ };
669
+ }
670
+
671
+ const raw = await readDialecticPending({ repoRoot });
672
+ // Applying without a sidecar is legitimate (the operator may never have run
673
+ // the dry-run) — that is NOT drift.
674
+ if (raw === null) return { ok: true, drifted: false, sidecarAbsent: true };
675
+
676
+ const sidecarBody = stripPendingFrontmatter(raw);
677
+ const drifted = normalizeTrailingNewline(sidecarBody) !== normalizeTrailingNewline(body);
678
+
679
+ return { ok: true, drifted, sidecarAbsent: false, sidecarBody, freshBody: body };
680
+ }
@@ -58,7 +58,7 @@ function parseNumeric(raw) {
58
58
  * silently to bounds. Unknown flags are ignored. `--dry-run` is a boolean flag.
59
59
  *
60
60
  * @param {string[]} argv — argument tokens (e.g. ['--max-sessions=3', '--dry-run'])
61
- * @returns {{maxSessions: number, maxHours: number, confidenceThreshold: number, dryRun: boolean}}
61
+ * @returns {{maxSessions: number, maxHours: number, confidenceThreshold: number, maxTokens: number, dryRun: boolean}}
62
62
  */
63
63
  export function parseFlags(argv) {
64
64
  const tokens = Array.isArray(argv) ? argv : [];
@@ -66,6 +66,7 @@ export function parseFlags(argv) {
66
66
  let rawSessions = null;
67
67
  let rawHours = null;
68
68
  let rawConfidence = null;
69
+ let rawTokens = null;
69
70
  let dryRun = false;
70
71
 
71
72
  for (const tok of tokens) {
@@ -81,6 +82,7 @@ export function parseFlags(argv) {
81
82
  if (key === '--max-sessions') rawSessions = parseNumeric(val);
82
83
  else if (key === '--max-hours') rawHours = parseNumeric(val);
83
84
  else if (key === '--confidence-threshold') rawConfidence = parseNumeric(val);
85
+ else if (key === '--max-tokens') rawTokens = parseNumeric(val);
84
86
  }
85
87
 
86
88
  return {
@@ -99,6 +101,15 @@ export function parseFlags(argv) {
99
101
  max: FLAG_BOUNDS.confidenceThreshold.max,
100
102
  fallback: FLAG_BOUNDS.confidenceThreshold.default,
101
103
  }),
104
+ // Integer token count — the TOKEN_BUDGET_EXCEEDED switch compares it against
105
+ // a summed `usage.output_tokens`, which is never fractional. `0` disables the
106
+ // switch (`kill-switches.mjs` guards on `maxTokens > 0`), so the lower bound
107
+ // stays 0 rather than 1.
108
+ maxTokens: Math.floor(clampNumber(rawTokens, {
109
+ min: FLAG_BOUNDS.maxTokens.min,
110
+ max: FLAG_BOUNDS.maxTokens.max,
111
+ fallback: FLAG_BOUNDS.maxTokens.default,
112
+ })),
102
113
  dryRun,
103
114
  };
104
115
  }
@@ -110,7 +110,8 @@ export function preIterationKillSwitch(args) {
110
110
  * @param {object | null | undefined} sessionResult
111
111
  * @param {object} opts
112
112
  * @param {number} opts.carryoverThreshold
113
- * @param {string} [opts.autopilotJsonlPath] — STALL_TIMEOUT sampler input
113
+ * @param {string} [opts.autopilotJsonlPath] — STALL_TIMEOUT sampler fallback marker
114
+ * @param {string} [opts.sessionLockPath] — STALL_TIMEOUT sampler primary marker
114
115
  * @param {number} [opts.stallTimeoutSeconds] — STALL_TIMEOUT threshold (default 600)
115
116
  * @param {() => number} [opts.nowMs] — wall-clock supplier (DI seam for tests)
116
117
  * @returns {{kill: string, detail: string} | null}
@@ -120,12 +121,14 @@ export function postSessionKillSwitch(sessionResult, opts) {
120
121
  const { carryoverThreshold } = opts;
121
122
 
122
123
  // STALL_TIMEOUT (ADR-364 §3, issue #371) — one-strike v1.
123
- // Sampler reads autopilot.jsonl mtime; missing file stallSeconds=0 → no kill
124
- // (documented contract: missing file is NOT a kill condition).
124
+ // Sampler prefers the session.lock heartbeat and falls back to autopilot.jsonl
125
+ // mtime; missing marker stallSeconds=0 no kill (documented contract:
126
+ // a missing file is NOT a kill condition).
125
127
  // Events route to autopilot.jsonl, NOT failures.jsonl (ADR-364 cross-connections rule 4).
126
128
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? 600;
127
129
  const stallSample = sampleProgress({
128
130
  autopilotJsonlPath: opts.autopilotJsonlPath,
131
+ sessionLockPath: opts.sessionLockPath,
129
132
  stallTimeoutSeconds,
130
133
  nowMs: opts.nowMs,
131
134
  });
@@ -59,7 +59,11 @@ function clampNumber(value, { min, max, fallback }) {
59
59
  * @param {string} [opts.jsonlPath] @param {string} [opts.runId] @param {string} [opts.branch]
60
60
  * @param {string} [opts.hostJsonPath] @param {number} [opts.peerAbortThreshold]
61
61
  * @param {number} [opts.carryoverThreshold] @param {number} [opts.maxTokens]
62
- * @param {string} [opts.autopilotJsonlPath] — STALL_TIMEOUT sampler input (defaults to jsonlPath)
62
+ * @param {string} [opts.autopilotJsonlPath] — STALL_TIMEOUT sampler fallback marker (defaults to jsonlPath)
63
+ * @param {string} [opts.sessionLockPath] — STALL_TIMEOUT sampler PRIMARY marker (`session.lock`
64
+ * `last_heartbeat`). Deliberately undefaulted: the default would be cwd-relative, which in a
65
+ * test run resolves to the LIVE repo lock and would silently decide the sampler's branch. The
66
+ * CLI driver (`scripts/autopilot.mjs`) supplies it; omitting it keeps the legacy mtime marker.
63
67
  * @param {number} [opts.stallTimeoutSeconds] — STALL_TIMEOUT threshold seconds (default 600)
64
68
  * @param {string} [opts.worktreePath] @param {string} [opts.parentRunId]
65
69
  * @param {number} [opts.blockedByIssue] — Phase D (#341) forward-compat: issue number this loop is waiting on (OPEN-4 commit-deps); callers may omit, defaults to null
@@ -125,6 +129,14 @@ export async function runLoop(opts = {}) {
125
129
  max_sessions: maxSessions,
126
130
  max_hours: maxHours,
127
131
  confidence_threshold: confidenceThreshold,
132
+ // Persisted for the same reason as the three above (HR-105): a switch whose
133
+ // input is never recorded cannot be falsified afterwards. `0` = disabled.
134
+ // CEILING: reaching a NON-zero value here does not make TOKEN_BUDGET_EXCEEDED
135
+ // live under the headless driver — `readTailSession()` in scripts/autopilot.mjs
136
+ // returns no `usage`, and sessions.jsonl carries no token field to build one
137
+ // from, so `total_tokens_used` stays 0. Revisit when a session record gains
138
+ // token counts.
139
+ max_tokens: opts.maxTokens ?? 0,
128
140
  iterations_completed: 0,
129
141
  kill_switch: null,
130
142
  kill_switch_detail: null,
@@ -275,6 +287,7 @@ export async function runLoop(opts = {}) {
275
287
  const postCheck = postSessionKillSwitch(sessionResult, {
276
288
  carryoverThreshold,
277
289
  autopilotJsonlPath: opts.autopilotJsonlPath ?? jsonlPath,
290
+ sessionLockPath: opts.sessionLockPath,
278
291
  stallTimeoutSeconds: opts.stallTimeoutSeconds ?? 600,
279
292
  nowMs: opts.nowMs,
280
293
  });