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
@@ -711,12 +711,33 @@ export function emitRewrite(updatedInput) {
711
711
  process.exit(0);
712
712
  };
713
713
 
714
- if (updatedInput === null || typeof updatedInput !== 'object' || Array.isArray(updatedInput)) {
715
- // `updatedInput` is a MAP in the bundle schema, so an array is as wrong as a
716
- // string — and an array would serialize into a shape the harness cannot use.
717
- bail(
718
- `was called with ${Array.isArray(updatedInput) ? 'an array' : String(updatedInput === null ? 'null' : typeof updatedInput)}, not a tool-input object`,
719
- );
714
+ // `updatedInput` is a MAP in the bundle schema, so an array is as wrong as a
715
+ // string and an array would serialize into a shape the harness cannot use.
716
+ //
717
+ // PLAIN object only, and `typeof === 'object'` is not that test. An object
718
+ // WRAPPER passes it and then serializes to something else entirely:
719
+ // `new Date()` and `new String('x')` become JSON STRINGS (their `toJSON` /
720
+ // primitive form), `new Map([...])` becomes `{}` — which looks valid and,
721
+ // per the doc above, deletes every field of the tool input. Each of the three
722
+ // reaches the harness as a schema-invalid `updatedInput`, and the bundle
723
+ // turns that into `behavior: "deny"`: on the `AskUserQuestion` path the
724
+ // operator's question is destroyed to save a rewrite. A prototype of
725
+ // `Object.prototype` (a literal) or `null` (`Object.create(null)`) is the
726
+ // whole admissible set; everything else takes the same no-op-plus-stderr
727
+ // contract as `null`, an array or a string.
728
+ const proto =
729
+ updatedInput !== null && typeof updatedInput === 'object'
730
+ ? Object.getPrototypeOf(updatedInput)
731
+ : undefined;
732
+ const isPlainObject =
733
+ !Array.isArray(updatedInput) && (proto === Object.prototype || proto === null);
734
+ if (!isPlainObject) {
735
+ let described;
736
+ if (Array.isArray(updatedInput)) described = 'an array';
737
+ else if (updatedInput === null) described = 'null';
738
+ else if (typeof updatedInput !== 'object') described = String(typeof updatedInput);
739
+ else described = `a non-plain object (${proto?.constructor?.name ?? 'unknown prototype'})`;
740
+ bail(`was called with ${described}, not a tool-input object`);
720
741
  }
721
742
 
722
743
  let line;
@@ -731,11 +752,24 @@ export function emitRewrite(updatedInput) {
731
752
  },
732
753
  });
733
754
  } catch (err) {
734
- // A cycle or a BigInt in the caller's object. JSON.stringify can also return
735
- // undefined (a toJSON that yields undefined) — caught by the same guard.
755
+ // A cycle or a BigInt in the caller's object.
736
756
  bail(`could not serialize the tool input (${err?.message ?? String(err)})`);
737
757
  }
738
758
 
759
+ // `JSON.stringify` returns `undefined` only for a TOP-LEVEL value it drops —
760
+ // and `updatedInput` sits NESTED inside the envelope literal above, so at this
761
+ // call site it never can. Measured 2026-09-18 with
762
+ // `{ toJSON() { return undefined; } }` as `updatedInput`: stdout is
763
+ // `{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}` (53 bytes) — the
764
+ // property is silently OMITTED, not undefined, and the guard below does not
765
+ // fire. That degraded state is the safe one (an envelope without
766
+ // `updatedInput` carries no rewrite, so the tool call runs with its original
767
+ // input — exactly what `bail` produces), which is why it is documented rather
768
+ // than checked: re-parsing up to REWRITE_ENVELOPE_MAX_BYTES on every rewrite
769
+ // to recover one stderr line is a poor trade. The guard stays because it is
770
+ // the envelope SHAPE that makes the case unreachable: hoist `updatedInput` to
771
+ // the top level, or hand the literal to a helper that returns it, and
772
+ // `undefined` becomes reachable again.
739
773
  if (typeof line !== 'string') {
740
774
  bail('serialized the tool input to undefined (a toJSON returning undefined?)');
741
775
  }
@@ -20,19 +20,24 @@
20
20
  * resolve to the wrong GitLab instance on a multi-host machine, turning
21
21
  * this whole module into a silent no-op.
22
22
  *
23
- * Usage (from session-end Phase 5 issue-close loop):
23
+ * Usage (session-end Phase 5 issue-close step) — ONE command strips, closes
24
+ * and verifies every resolved issue:
24
25
  *
25
- * import { stripStatusLabels } from '${PLUGIN_ROOT}/scripts/lib/issue-close-strip-labels.mjs';
26
+ * node scripts/lib/issue-close-strip-labels.mjs --close --vcs gitlab 1388 1387
26
27
  *
27
- * const { stripped, error } = await stripStatusLabels({ issueId: 42, vcs: 'gitlab' });
28
- * if (error) console.warn(`⚠ label strip failed for #${issueId}: ${error}`);
29
- * else if (stripped.length) console.log(`Stripped ${stripped.join(', ')} from #${issueId}`);
30
- * // then close the issue with glab/gh as usual
28
+ * It prints one JSON line per id and exits 1 when any id did not verify as
29
+ * closed. The mechanical call exists because the earlier prose-only step
30
+ * ("call stripStatusLabels, then close") was skipped often enough that 339
31
+ * closed issues still carried `status:in-progress` (measured 2026-09-19).
32
+ * `closeIssues()` is the same sequence as a library call; `stripStatusLabels()`
33
+ * stays exported for callers that only strip.
31
34
  *
32
35
  * Auto-created by `scripts/lib/issue-close-strip-labels.mjs` (#308).
33
36
  */
34
37
 
35
38
  import { execFileSync } from 'node:child_process';
39
+ import { parseArgs } from 'node:util';
40
+ import { isMainModule } from './is-main-module.mjs';
36
41
  import { resolveRepoSpec } from './vcs-repo-spec.mjs';
37
42
 
38
43
  /** Regex that matches any `status:*` label name. */
@@ -43,11 +48,12 @@ const STATUS_LABEL_RE = /^status:/;
43
48
  *
44
49
  * @param {string} cmd
45
50
  * @param {string[]} args
51
+ * @param {typeof execFileSync} [execFile] injectable executor (tests)
46
52
  * @returns {{ ok: boolean, stdout: string, stderr: string }}
47
53
  */
48
- function runCli(cmd, args) {
54
+ function runCli(cmd, args, execFile = execFileSync) {
49
55
  try {
50
- const stdout = execFileSync(cmd, args, {
56
+ const stdout = execFile(cmd, args, {
51
57
  encoding: 'utf8',
52
58
  stdio: ['ignore', 'pipe', 'pipe'],
53
59
  });
@@ -61,50 +67,50 @@ function runCli(cmd, args) {
61
67
  }
62
68
 
63
69
  /**
64
- * Fetch the label names currently applied to an issue.
70
+ * Read an issue as JSON via `glab issue view --output json` (every field) or
71
+ * `gh issue view --json <ghFields>` (only the named fields).
65
72
  *
66
- * @param {{ issueId: number | string, vcs: 'gitlab' | 'github', spec?: string }} opts
73
+ * @param {{ issueId: string, vcs: 'gitlab' | 'github', spec?: string,
74
+ * ghFields: string, execFile?: typeof execFileSync }} opts
67
75
  * `spec` is the resolved `-R`/`--repo` host-pinning value (#839); omitted
68
76
  * (undefined) means no `-R` is appended — never emit `-R undefined`.
69
- * @returns {{ ok: boolean, labels: string[], stderr: string }}
77
+ * @returns {{ ok: boolean, data: any, stderr: string }}
70
78
  */
71
- function fetchLabels({ issueId, vcs, spec }) {
72
- const id = String(issueId);
73
-
74
- if (vcs === 'github') {
75
- // `gh issue view <NUMBER> --json labels` returns { labels: [{name, ...}] }
76
- const args = ['issue', 'view', id, '--json', 'labels'];
77
- if (spec) args.push('-R', spec);
78
- const res = runCli('gh', args);
79
- if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
80
- let parsed;
81
- try {
82
- parsed = JSON.parse(res.stdout);
83
- } catch (e) {
84
- return { ok: false, labels: [], stderr: `JSON parse failed: ${e && e.message ? e.message : String(e)}` };
85
- }
86
- const labels = Array.isArray(parsed?.labels)
87
- ? parsed.labels.map((l) => (typeof l?.name === 'string' ? l.name : String(l))).filter(Boolean)
88
- : [];
89
- return { ok: true, labels, stderr: '' };
90
- }
91
-
92
- // Default: gitlab via glab.
93
- // `glab issue view <IID> --output json` returns an issue object with a `labels` array.
94
- const args = ['issue', 'view', id, '--output', 'json'];
79
+ function viewIssueJson({ issueId, vcs, spec, ghFields, execFile }) {
80
+ const args =
81
+ vcs === 'github'
82
+ ? ['issue', 'view', issueId, '--json', ghFields]
83
+ : ['issue', 'view', issueId, '--output', 'json'];
95
84
  if (spec) args.push('-R', spec);
96
- const res = runCli('glab', args);
97
- if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
98
- let parsed;
85
+ const res = runCli(vcs === 'github' ? 'gh' : 'glab', args, execFile);
86
+ if (!res.ok) return { ok: false, data: null, stderr: res.stderr };
99
87
  try {
100
- parsed = JSON.parse(res.stdout);
88
+ return { ok: true, data: JSON.parse(res.stdout), stderr: '' };
101
89
  } catch (e) {
102
- return { ok: false, labels: [], stderr: `JSON parse failed: ${e && e.message ? e.message : String(e)}` };
90
+ return {
91
+ ok: false,
92
+ data: null,
93
+ stderr: `JSON parse failed: ${e && e.message ? e.message : String(e)}`,
94
+ };
103
95
  }
104
- // GitLab JSON shape: { labels: ["status:ready", "priority::high", ...] }
105
- const labels = Array.isArray(parsed?.labels)
106
- ? parsed.labels.map((l) => (typeof l === 'string' ? l : String(l))).filter(Boolean)
107
- : [];
96
+ }
97
+
98
+ /**
99
+ * Fetch the label names currently applied to an issue.
100
+ *
101
+ * GitHub shape: `{ labels: [{ name }] }`; GitLab shape: `{ labels: ["status:ready", ...] }`.
102
+ *
103
+ * @param {{ issueId: string, vcs: 'gitlab' | 'github', spec?: string, execFile?: typeof execFileSync }} opts
104
+ * @returns {{ ok: boolean, labels: string[], stderr: string }}
105
+ */
106
+ function fetchLabels({ issueId, vcs, spec, execFile }) {
107
+ const { ok, data, stderr } = viewIssueJson({ issueId, vcs, spec, ghFields: 'labels', execFile });
108
+ if (!ok) return { ok: false, labels: [], stderr };
109
+ const toName =
110
+ vcs === 'github'
111
+ ? (l) => (typeof l?.name === 'string' ? l.name : String(l))
112
+ : (l) => (typeof l === 'string' ? l : String(l));
113
+ const labels = Array.isArray(data?.labels) ? data.labels.map(toName).filter(Boolean) : [];
108
114
  return { ok: true, labels, stderr: '' };
109
115
  }
110
116
 
@@ -119,12 +125,14 @@ function fetchLabels({ issueId, vcs, spec }) {
119
125
  * issueId: number | string,
120
126
  * vcs?: 'gitlab' | 'github',
121
127
  * repoRoot?: string,
122
- * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
128
+ * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined,
129
+ * execFile?: typeof execFileSync
123
130
  * }} opts
124
131
  * `repoRoot` defaults to `process.cwd()`. `resolveRepoSpecFn` is the
125
132
  * injectable seam for the `-R`/`--repo` host-pinning resolution (#839) —
126
133
  * defaults to the real `resolveRepoSpec` (shells out to `git remote
127
- * get-url`); tests inject a stub instead of shelling out.
134
+ * get-url`); tests inject a stub instead of shelling out. `execFile`
135
+ * (default `execFileSync`) runs every glab/gh call.
128
136
  * @returns {Promise<{ stripped: string[], error?: string }>}
129
137
  */
130
138
  export async function stripStatusLabels({
@@ -132,6 +140,7 @@ export async function stripStatusLabels({
132
140
  vcs = 'gitlab',
133
141
  repoRoot = process.cwd(),
134
142
  resolveRepoSpecFn = resolveRepoSpec,
143
+ execFile = execFileSync,
135
144
  } = {}) {
136
145
  try {
137
146
  const id = String(issueId ?? '').trim();
@@ -146,7 +155,7 @@ export async function stripStatusLabels({
146
155
  const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
147
156
 
148
157
  // Step 1: fetch current labels.
149
- const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved, spec });
158
+ const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved, spec, execFile });
150
159
  if (!ok) {
151
160
  return { stripped: [], error: `failed to fetch labels: ${stderr}` };
152
161
  }
@@ -167,13 +176,13 @@ export async function stripStatusLabels({
167
176
  args.push('--remove-label', label);
168
177
  }
169
178
  if (spec) args.push('-R', spec);
170
- stripRes = runCli('gh', args);
179
+ stripRes = runCli('gh', args, execFile);
171
180
  } else {
172
181
  // `glab issue update <IID> --unlabel label1,label2,...`
173
182
  // glab accepts comma-separated list in a single --unlabel flag value.
174
183
  const args = ['issue', 'update', id, '--unlabel', toStrip.join(',')];
175
184
  if (spec) args.push('-R', spec);
176
- stripRes = runCli('glab', args);
185
+ stripRes = runCli('glab', args, execFile);
177
186
  }
178
187
 
179
188
  if (!stripRes.ok) {
@@ -189,3 +198,152 @@ export async function stripStatusLabels({
189
198
  return { stripped: [], error: msg };
190
199
  }
191
200
  }
201
+
202
+ /**
203
+ * Strip `status:*` labels, close, and VERIFY the close for each issue id — in
204
+ * that order, one id at a time.
205
+ *
206
+ * `closed` is true only when a re-read of the issue AFTER the close says
207
+ * `state: closed` (GitLab `closed`, GitHub `CLOSED`). The close command's own
208
+ * exit status is not trusted: a wrong project or a silent 404 can exit 0 while
209
+ * closing nothing (gitlab-ops skill, "Close verification"). The re-read runs
210
+ * even when the close command failed, so an issue that was already closed
211
+ * reports `closed: true` alongside the close `error`.
212
+ *
213
+ * A strip failure is non-fatal: it is reported as `stripError` and the close
214
+ * still runs. An id that is not a positive integer (an optional leading `#` is
215
+ * accepted) gets no CLI call at all — a value starting with `-` would
216
+ * otherwise reach glab/gh as a flag.
217
+ *
218
+ * @param {{
219
+ * ids: Array<number | string>,
220
+ * vcs?: 'gitlab' | 'github',
221
+ * repo?: string,
222
+ * repoRoot?: string,
223
+ * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined,
224
+ * execFile?: typeof execFileSync
225
+ * }} opts
226
+ * `repo` overrides the `-R`/`--repo` spec; otherwise it is resolved once via
227
+ * `resolveRepoSpecFn` exactly as `stripStatusLabels` does (#839).
228
+ * @returns {Promise<Array<{ id: string, stripped: string[], stripError?: string,
229
+ * closed: boolean, state: string | null, error?: string }>>}
230
+ */
231
+ export async function closeIssues({
232
+ ids = [],
233
+ vcs = 'gitlab',
234
+ repo,
235
+ repoRoot = process.cwd(),
236
+ resolveRepoSpecFn = resolveRepoSpec,
237
+ execFile = execFileSync,
238
+ } = {}) {
239
+ const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
240
+ const spec = repo?.trim() || resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
241
+ const cli = vcsResolved === 'github' ? 'gh' : 'glab';
242
+ const results = [];
243
+
244
+ for (const rawId of ids) {
245
+ const id = String(rawId ?? '')
246
+ .trim()
247
+ .replace(/^#/, '');
248
+ if (!/^[1-9]\d*$/.test(id)) {
249
+ results.push({
250
+ id: String(rawId),
251
+ stripped: [],
252
+ closed: false,
253
+ state: null,
254
+ error: 'invalid issue id',
255
+ });
256
+ continue;
257
+ }
258
+ const result = { id, stripped: [], closed: false, state: null };
259
+
260
+ // 1. strip — non-fatal. The resolved spec is handed through so strip,
261
+ // close and verify all pin the same repo.
262
+ const strip = await stripStatusLabels({
263
+ issueId: id,
264
+ vcs: vcsResolved,
265
+ resolveRepoSpecFn: () => spec,
266
+ execFile,
267
+ });
268
+ result.stripped = strip.stripped;
269
+ if (strip.error) result.stripError = strip.error;
270
+
271
+ // 2. close
272
+ const closeArgs = ['issue', 'close', id];
273
+ if (spec) closeArgs.push('-R', spec);
274
+ const closeRes = runCli(cli, closeArgs, execFile);
275
+ const errors = closeRes.ok
276
+ ? []
277
+ : [`close failed: ${closeRes.stderr.trim() || 'unknown error'}`];
278
+
279
+ // 3. verify — the platform's state is the only evidence of a close.
280
+ const view = viewIssueJson({
281
+ issueId: id,
282
+ vcs: vcsResolved,
283
+ spec,
284
+ ghFields: 'state',
285
+ execFile,
286
+ });
287
+ if (view.ok) {
288
+ result.state = typeof view.data?.state === 'string' ? view.data.state : null;
289
+ result.closed = result.state?.toLowerCase() === 'closed';
290
+ if (!result.closed) {
291
+ errors.push(`verify: issue state is ${result.state ?? 'unknown'}, not closed`);
292
+ }
293
+ } else {
294
+ errors.push(`verify failed: ${view.stderr.trim() || 'unknown error'}`);
295
+ }
296
+ if (errors.length) result.error = errors.join('; ');
297
+ results.push(result);
298
+ }
299
+ return results;
300
+ }
301
+
302
+ const USAGE =
303
+ 'usage: issue-close-strip-labels.mjs --close [--vcs gitlab|github] [-R|--repo <spec>] <id>...';
304
+
305
+ /**
306
+ * CLI: `--close [--vcs gitlab|github] [-R <spec>] <id>...` — one JSON line per
307
+ * id on stdout. Exit 0 = every id verified closed, 1 = at least one did not,
308
+ * 2 = usage error (no GitLab/GitHub call made).
309
+ *
310
+ * @param {string[]} [argv]
311
+ * @returns {Promise<number>} exit code
312
+ */
313
+ async function main(argv = process.argv.slice(2)) {
314
+ let parsed;
315
+ try {
316
+ parsed = parseArgs({
317
+ args: argv,
318
+ allowPositionals: true,
319
+ options: {
320
+ close: { type: 'boolean', default: false },
321
+ vcs: { type: 'string', default: 'gitlab' },
322
+ repo: { type: 'string', short: 'R' },
323
+ },
324
+ });
325
+ } catch (err) {
326
+ process.stderr.write(`${err?.message ?? err}\n${USAGE}\n`);
327
+ return 2;
328
+ }
329
+ const { values, positionals } = parsed;
330
+ if (!values.close || positionals.length === 0 || !['gitlab', 'github'].includes(values.vcs)) {
331
+ process.stderr.write(`${USAGE}\n`);
332
+ return 2;
333
+ }
334
+
335
+ const results = await closeIssues({ ids: positionals, vcs: values.vcs, repo: values.repo });
336
+ for (const r of results) {
337
+ if (r.stripError) {
338
+ process.stderr.write(
339
+ `⚠ label strip failed for #${r.id}: ${r.stripError} — close attempted anyway\n`,
340
+ );
341
+ }
342
+ process.stdout.write(`${JSON.stringify(r)}\n`);
343
+ }
344
+ return results.every((r) => r.closed) ? 0 : 1;
345
+ }
346
+
347
+ if (isMainModule(import.meta.url)) {
348
+ process.exitCode = await main();
349
+ }
@@ -0,0 +1,197 @@
1
+ // js-mask.mjs — ONE JavaScript lexer for every "blank the comments / separate
2
+ // code from data" scanner in this repo.
3
+ //
4
+ // WHY IT LIVES HERE. `maskSource` grew inside
5
+ // `scripts/lib/validate/check-untracked-test-deps.mjs`, where it is used to
6
+ // separate CODE from DATA. Three OTHER modules had hand-rolled
7
+ // `stripComments` copies with NO regex-literal branch, and each one was
8
+ // measurably wrong for the same input shape (`/\/*$/` — a regex whose body
9
+ // contains `/*`), measured 2026-09-18 @ 20a4cbff:
10
+ // - `check-validator-registration.mjs` — the regex's `/*` opened a block
11
+ // comment that swallowed the rest of the surface text → a FALSE
12
+ // "UNREGISTERED" finding.
13
+ // - `check-test-git-config-target.mjs` — same swallow inside a call tail →
14
+ // `wrapperHasCwd` returned false → a false alarm.
15
+ // - `auq/parse.mjs` — the swallowed region no longer contained
16
+ // `(Recommended)` → a silent FALSE NEGATIVE in the AUQ clarity measurement.
17
+ // One lexer, five consumers: the regex branch and its three named ceilings are
18
+ // maintained once.
19
+ //
20
+ // Import-safety: importing this module executes nothing.
21
+
22
+ /**
23
+ * Keywords after which a `/` opens a REGEX LITERAL, never a division. The
24
+ * single-character `prev` below cannot tell `return /x/` from `n / x`, because
25
+ * the character it sees (`n`) is a word character in both. This set is the
26
+ * one-token lookback that closes the difference; it is deliberately the usual
27
+ * short list rather than a parser (BV-001.6/BV-004 — a full expression parser
28
+ * for a masker is the abstraction this file exists to avoid).
29
+ *
30
+ * Named ceiling (BV-004), in the other direction: a genuine DIVISION CHAIN after
31
+ * one of these words (`return a / b / c`) is now read as a regex and its middle
32
+ * blanked. Measured 2026-09-18 over `git ls-files '*.mjs'` (1245 files): 0
33
+ * occurrences — all 18 files whose masking this lookback changes carry a real
34
+ * `return /…/` literal. REVISIT TRIGGER: the first such division chain.
35
+ */
36
+ const REGEX_PRECEDING_KEYWORDS = new Set([
37
+ 'return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'yield',
38
+ 'await', 'instanceof', 'do', 'else',
39
+ ]);
40
+
41
+ /** Word characters, for the one-token lookback above. */
42
+ const WORD_CHAR_RE = /[A-Za-z0-9_$]/;
43
+
44
+ /**
45
+ * Blank out the INTERIOR of every string literal, template literal, comment and
46
+ * regex literal, preserving byte offsets and newlines. Delimiters are kept so
47
+ * paren/comma balance and identifier boundaries survive.
48
+ *
49
+ * This is what separates CODE from DATA. A test that embeds fixture source in a
50
+ * template literal (`check-untracked-test-deps.mjs`'s own test file does) must
51
+ * not be scanned as though that fixture were its own body — masking makes that
52
+ * structural instead of relying on a self-exemption marker.
53
+ *
54
+ * With `keepLiterals: true` only COMMENTS are blanked: string, template and
55
+ * regex literals are still recognised (so a `//` or `/*` inside one is never read
56
+ * as a comment, and a quote inside a regex never opens a string) but their
57
+ * contents stay intact. `check-entry-guard.mjs` needs exactly that — it must see
58
+ * `endsWith('x.mjs')` and `${process.argv[1]}` verbatim (#1383) — and it is the
59
+ * mode the three `stripComments` consumers delegate in (#1388).
60
+ *
61
+ * @param {string} text
62
+ * @param {{ keepLiterals?: boolean }} [options]
63
+ * @returns {string} same-length masked source
64
+ */
65
+ export function maskSource(text, { keepLiterals = false } = {}) {
66
+ const out = text.split('');
67
+ const n = out.length;
68
+ let i = 0;
69
+ // Last significant (non-space) char before `i`, and its offset, used for the
70
+ // regex/division disambiguation below (the offset carries the one-token
71
+ // keyword lookback).
72
+ let prev = '';
73
+ let prevIdx = -1;
74
+
75
+ /** The whole word ending at `idx`, or '' when `idx` is not a word char. */
76
+ const wordEndingAt = (idx) => {
77
+ if (idx < 0 || !WORD_CHAR_RE.test(text[idx])) return '';
78
+ let k = idx;
79
+ while (k > 0 && WORD_CHAR_RE.test(text[k - 1])) k--;
80
+ return text.slice(k, idx + 1);
81
+ };
82
+
83
+ const blank = (from, to) => {
84
+ for (let k = from; k < to && k < n; k++) if (out[k] !== '\n') out[k] = ' ';
85
+ };
86
+ // Literal interiors go through this one; comments always use `blank`.
87
+ const blankLiteral = keepLiterals ? () => {} : blank;
88
+
89
+ while (i < n) {
90
+ const c = text[i];
91
+ const next = text[i + 1];
92
+
93
+ // Line comment.
94
+ if (c === '/' && next === '/') {
95
+ let j = i;
96
+ while (j < n && text[j] !== '\n') j++;
97
+ blank(i, j);
98
+ i = j;
99
+ continue;
100
+ }
101
+ // Block comment — ONLY when it is actually closed.
102
+ //
103
+ // Named ceiling (BV-004), replacing an unbounded blank: an UNTERMINATED
104
+ // `/*` used to blank everything to EOF. In valid source that shape is a
105
+ // syntax error, so in practice it only ever arises from a lexer desync —
106
+ // a `/*` inside a regex literal the branch below failed to recognise
107
+ // (`return /\/*$/`). Blanking the rest of the file then hides every guard
108
+ // under it, which is a validator falling SILENT. The direction is therefore
109
+ // chosen fail-toward-REPORTING: an unclosed `/*` is not a comment at all,
110
+ // and the few bytes of a genuine one get scanned as code — at worst that
111
+ // produces a finding a human dismisses, never a silent pass.
112
+ // REVISIT TRIGGER: a tracked file that legitimately ends inside an
113
+ // unterminated block comment (i.e. the moment `node --check` accepts one).
114
+ if (c === '/' && next === '*') {
115
+ let j = i + 2;
116
+ while (j < n && !(text[j] === '*' && text[j + 1] === '/')) j++;
117
+ if (j < n) {
118
+ blank(i, j + 2);
119
+ i = j + 2;
120
+ continue;
121
+ }
122
+ // Unterminated — fall through and treat the `/` as ordinary source.
123
+ }
124
+ // Single/double-quoted string — keep the quotes, blank the interior.
125
+ if (c === "'" || c === '"') {
126
+ let j = i + 1;
127
+ while (j < n && text[j] !== c) {
128
+ if (text[j] === '\\') j++;
129
+ if (text[j] === '\n') break; // unterminated — bail at EOL
130
+ j++;
131
+ }
132
+ blankLiteral(i + 1, j);
133
+ i = Math.min(j + 1, n);
134
+ prev = c;
135
+ prevIdx = i - 1;
136
+ continue;
137
+ }
138
+ // Template literal — blank everything including `${…}` substitutions.
139
+ if (c === '`') {
140
+ let j = i + 1;
141
+ while (j < n && text[j] !== '`') {
142
+ if (text[j] === '\\') j++;
143
+ j++;
144
+ }
145
+ blankLiteral(i + 1, j);
146
+ i = Math.min(j + 1, n);
147
+ prev = c;
148
+ prevIdx = i - 1;
149
+ continue;
150
+ }
151
+ // Regex literal — only when the previous significant TOKEN cannot end an
152
+ // expression, AND a closing unescaped `/` exists on the SAME line. The
153
+ // same-line bound keeps a misread division from swallowing the file.
154
+ //
155
+ // The token is either the single char `prev`, or — when that char is a word
156
+ // character — the whole word ending at it, checked against
157
+ // REGEX_PRECEDING_KEYWORDS. Without that lookback `return /\/*$/` read as a
158
+ // division and its `/*` reached the block-comment branch above, which blanked
159
+ // the rest of the file: a validator silently passing (measured 2026-09-18).
160
+ //
161
+ // Named ceiling (BV-004): the lookback is exactly ONE token. A regex after
162
+ // any other non-expression-ending context — a `)` that closes `if (…)`, a
163
+ // statement label, `function` — is still read as division and its body
164
+ // scanned as code. That desync is now bounded on BOTH sides: a quote in it
165
+ // opens a string that bails at EOL, and a `/*` in it no longer blanks past
166
+ // the file (unterminated block comments are not comments — see above).
167
+ // REVISIT TRIGGER: a census finding that needs `)`-aware lookback, i.e. a
168
+ // regex used directly as the body of `if (…) /re/.test(x)`.
169
+ const startsRegex = c === '/' && (
170
+ prev === '' || '(,=:[!&|?{};+-*%~^<>'.includes(prev)
171
+ || REGEX_PRECEDING_KEYWORDS.has(wordEndingAt(prevIdx))
172
+ );
173
+ if (startsRegex) {
174
+ let j = i + 1;
175
+ let inClass = false;
176
+ let closed = -1;
177
+ while (j < n && text[j] !== '\n') {
178
+ if (text[j] === '\\') { j += 2; continue; }
179
+ if (text[j] === '[') inClass = true;
180
+ else if (text[j] === ']') inClass = false;
181
+ else if (text[j] === '/' && !inClass) { closed = j; break; }
182
+ j++;
183
+ }
184
+ if (closed > i) {
185
+ blankLiteral(i + 1, closed);
186
+ i = closed + 1;
187
+ prev = '/';
188
+ prevIdx = closed;
189
+ continue;
190
+ }
191
+ }
192
+
193
+ if (!/\s/.test(c)) { prev = c; prevIdx = i; }
194
+ i++;
195
+ }
196
+ return out.join('');
197
+ }
@@ -11,13 +11,17 @@
11
11
  * a CODE call site instead: `emitEvolveCompleted()` is invoked from
12
12
  * `scripts/sweep-expired-learnings.mjs`'s `--prune` exit path (the ONE store
13
13
  * write `/evolve analyze` already performs), and `recordDialecticRun()` is
14
- * invoked from `scripts/dialectic-deriver.mjs::runDialecticDeriver()` for the
15
- * three abort statuses it can determine on its own, plus dry-run success —
16
- * see that module's header for why APPLY-mode success and the two
17
- * throw-based abort classes (`unknown-model`, `subagent-crash`) still call
18
- * `recordDialecticRun()` from `skills/evolve/SKILL.md` prose (the merge and
19
- * the dispatch-agent try/catch both happen one layer up, outside this pure
20
- * pipeline).
14
+ * invoked from `scripts/dialectic-deriver.mjs::runDialecticDeriver()` for
15
+ * every outcome that pipeline can determine on its own: the `ok`/abort
16
+ * returns (`empty-input`, `budget-exceeded`, `would-empty-card`, dry-run
17
+ * success) AND the two throw-based abort classes (`unknown-model`,
18
+ * `subagent-crash`), the latter via that module's `recordThrownAbort()`
19
+ * helper which records at the throw point before the original error is
20
+ * rethrown. Only APPLY-mode success still calls `recordDialecticRun()` from
21
+ * `skills/evolve/SKILL.md` prose (Step 6.4 of
22
+ * `skills/evolve/references/evolve-dialectic-mode.md`) — the merge
23
+ * (`mergePeerCard()`) happens one layer up, outside this pure pipeline, so the
24
+ * real merge-stats deltas are not knowable inside `runDialecticDeriver()`.
21
25
  *
22
26
  * Both emitters are best-effort and try/catch-wrapped — same posture as
23
27
  * `emitReconcileCompleted` in `scripts/lib/reconcile/engine.mjs` — because