session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -47,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
47
47
  ]);
48
48
  }
49
49
 
50
+ /** C0 controls plus DEL — matching them is the POINT, hence the disable. */
51
+ // eslint-disable-next-line no-control-regex
52
+ const CONTROL_BYTE_RE = /[\u0000-\u001f\u007f]/g;
53
+
54
+ /**
55
+ * Replace every C0/DEL control byte with its printable `\uXXXX` escape.
56
+ *
57
+ * `JSON.stringify` covers the payload preview, but NOT `SyntaxError.message` —
58
+ * V8 quotes the offending input INTO that message verbatim, so an ANSI/CR
59
+ * payload reached the operator's terminal through the error text even after
60
+ * the preview was escaped. Both halves go through here.
61
+ *
62
+ * @param {unknown} text
63
+ * @returns {string}
64
+ */
65
+ function escapeControlBytes(text) {
66
+ return String(text).replace(
67
+ CONTROL_BYTE_RE,
68
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Name a parsed JSON value's type WITHOUT quoting any of its content.
74
+ *
75
+ * @param {unknown} value
76
+ * @returns {'null'|'array'|'object'|'string'|'number'|'boolean'|'undefined'}
77
+ */
78
+ function jsonTypeOf(value) {
79
+ if (value === null) return 'null';
80
+ if (Array.isArray(value)) return 'array';
81
+ return /** @type {any} */ (typeof value);
82
+ }
83
+
84
+ /**
85
+ * Parse CLI stdout as JSON, degrading an unparseable payload onto this module's
86
+ * documented failure channel instead of a bare `SyntaxError` (CWE-502).
87
+ *
88
+ * The throw IS that channel, not an escape from it: every call site runs under
89
+ * `checkCiStatus`'s outer catch, whose comment already names "unparseable
90
+ * output" as a case it converts to `console.warn` + `null`. Measured
91
+ * 2026-08-28 at 30940cb, BEFORE this helper existed: an HTML login page from
92
+ * `gh repo view`, a literal `null`, and an empty `glab` stdout ALL already
93
+ * returned `null` with a warn — nothing crashed, and
94
+ * `tests/lib/ci-status-banner.test.mjs` § "error containment" pinned it. So
95
+ * this does not fix a crash — the outer catch already turned non-JSON output
96
+ * into a warn + `null`. The rule this helper closes is lexical
97
+ * (`json-parse-untrusted-input`, see below), not a crash it prevents.
98
+ *
99
+ * What it does fix is the message. The raw parse error (`Unexpected token
100
+ * '<'`) named neither the CLI nor the request, and this banner spawns four
101
+ * different subprocesses — an operator reading that line at session-start
102
+ * could not tell which one returned garbage, which is the same
103
+ * "could not read looks like nothing to report" class #1022/#1039 attacked.
104
+ * Guarding at the parse also satisfies the `json-parse-untrusted-input` rule,
105
+ * which keys on a LEXICALLY enclosing try/catch and cannot see the outer one.
106
+ *
107
+ * Ceiling (BV-004): the payload preview is clamped to 120 characters. An
108
+ * unbounded one would bury the session-start banner it is printed beside — a
109
+ * paginated HTML error page is the realistic worst case. Revisit if a CLI
110
+ * starts emitting a diagnostic that needs more than one line to identify.
111
+ *
112
+ * The preview is emitted through `JSON.stringify`, not raw. It comes from a
113
+ * subprocess whose stdout this module does not control and lands in a
114
+ * `console.warn` beside the session-start banner: a payload carrying ANSI
115
+ * escapes, a `\r`, or a bare newline could otherwise repaint or overwrite the
116
+ * lines around it. `JSON.stringify` escapes every control byte and quotes the
117
+ * result, so the preview stays exactly one line of printable text.
118
+ *
119
+ * `expect` closes the second half of the same gap: valid JSON of the WRONG
120
+ * SHAPE parsed fine and escaped this named channel entirely. Measured
121
+ * 2026-08-28 at 7daa3d2 — a `gh repo view` that printed `null` reached
122
+ * `const { nameWithOwner } = …` and threw a bare
123
+ * `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, which
124
+ * names neither the CLI nor the request; a `glab api …/pipelines` that printed
125
+ * `null` was swallowed by `!Array.isArray(pipelines) → return null`, silent.
126
+ * Both now fail as the SAME named error the parse failure produces.
127
+ *
128
+ * @param {string} stdout Raw child-process stdout (untrusted)
129
+ * @param {string} label The command that produced it, for the failure message
130
+ * @param {'object'|'array'} [expect] Required shape; omit to accept any JSON
131
+ * @returns {unknown} The parsed value
132
+ * @throws {Error} Named parse/shape failure carrying a bounded payload preview
133
+ */
134
+ function parseCliJson(stdout, label, expect) {
135
+ const raw = String(stdout ?? '');
136
+ // Bounded (BV-004: 120 chars — a paginated HTML error page is the realistic
137
+ // worst case) AND escaped, so it can never break the line it is printed on.
138
+ const preview = raw.trim().slice(0, 120);
139
+ const shown = preview
140
+ ? `got: ${escapeControlBytes(JSON.stringify(preview))}`
141
+ : 'got: (empty stdout)';
142
+
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ } catch (err) {
147
+ const reason = escapeControlBytes(err instanceof Error ? err.message : String(err));
148
+ // `cause` preserves the original for a debugger; the reason is ALSO
149
+ // inlined into the message because the outer catch reads `err.message`
150
+ // only — a cause-only wrapper would lose it on the operator-facing line.
151
+ throw new Error(
152
+ `${label} returned unparseable JSON (${reason}) — ${shown}`,
153
+ { cause: err },
154
+ );
155
+ }
156
+
157
+ // A shape mismatch reports the JSON TYPE, never the payload. The parse
158
+ // succeeded, so the bytes add nothing an operator can act on — and
159
+ // `tests/lib/ci-status-banner.test.mjs` § "unexpected benign pipeline
160
+ // metadata" pins that a well-formed-but-wrong-shaped API body must not have
161
+ // its contents echoed anywhere. A type name carries no body content.
162
+ const actual = jsonTypeOf(parsed);
163
+ if (expect === 'array' && actual !== 'array') {
164
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an array, got ${actual}`);
165
+ }
166
+ if (expect === 'object' && actual !== 'object') {
167
+ throw new Error(`${label} returned JSON of an unexpected shape — expected an object, got ${actual}`);
168
+ }
169
+ return parsed;
170
+ }
171
+
50
172
  /**
51
173
  * The one failure this probe can produce that the frozen
52
174
  * `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
@@ -201,16 +323,17 @@ async function getHeadSha(repoRoot, deps = {}) {
201
323
  * @param {string} apiPath
202
324
  * @param {string} repoRoot
203
325
  * @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
326
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
204
327
  * @returns {Promise<unknown>}
205
328
  */
206
- async function glabApi(apiPath, repoRoot, deps = {}) {
329
+ async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
207
330
  const args = ['api', apiPath, '--hostname', deps.repoHost];
208
331
  const result = await execWithTimeout(
209
332
  'glab',
210
333
  args,
211
334
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
212
335
  );
213
- return JSON.parse(result.stdout);
336
+ return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
214
337
  }
215
338
 
216
339
  /**
@@ -222,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
222
345
  * @param {string} apiPath
223
346
  * @param {string} repoRoot
224
347
  * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
348
+ * @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
225
349
  * @returns {Promise<unknown>}
226
350
  */
227
- async function ghApi(apiPath, repoRoot, deps = {}) {
351
+ async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
228
352
  const args = ['api', apiPath];
229
353
  if (deps.repoHost) args.push('--hostname', deps.repoHost);
230
354
  const result = await execWithTimeout(
@@ -232,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
232
356
  args,
233
357
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
234
358
  );
235
- return JSON.parse(result.stdout);
359
+ return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
236
360
  }
237
361
 
238
362
  /**
@@ -276,14 +400,18 @@ async function checkGitlab(repoRoot, now, deps = {}) {
276
400
  const currentSha = await getHeadSha(repoRoot, deps);
277
401
  const apiDeps = { ...deps, repoHost: project.host };
278
402
  const projectPath = `projects/${project.encodedProjectPath}`;
403
+ // `'array'` is load-bearing, not decoration: before it, a `glab api` that
404
+ // returned valid JSON of the wrong shape (`null`, `"ok"`, an object) fell
405
+ // into `!Array.isArray(pipelines) → return null` — a SILENT no-op an operator
406
+ // reads as "nothing to report". It now raises the same named error an
407
+ // unparseable payload does, so the outer catch warns.
279
408
  const pipelines = await glabApi(
280
409
  `${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
281
410
  repoRoot,
282
411
  apiDeps,
412
+ 'array',
283
413
  );
284
414
 
285
- if (!Array.isArray(pipelines)) return null;
286
-
287
415
  const currentPipeline = pipelines.find((p) => p.sha === currentSha);
288
416
 
289
417
  if (!currentPipeline) {
@@ -312,6 +440,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
312
440
  `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
313
441
  repoRoot,
314
442
  apiDeps,
443
+ 'array',
315
444
  );
316
445
  if (Array.isArray(jobs)) {
317
446
  const softFailed = jobs
@@ -379,6 +508,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
379
508
  `${projectPath}/pipelines/${currentPipeline.id}/jobs`,
380
509
  repoRoot,
381
510
  apiDeps,
511
+ 'array',
382
512
  );
383
513
  if (Array.isArray(jobs)) {
384
514
  const failedJob = jobs.find((j) => j.status === 'failed');
@@ -450,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
450
580
  repoViewArgs,
451
581
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
452
582
  );
453
- const { nameWithOwner } = JSON.parse(repoViewResult.stdout);
583
+ // `'object'` before the destructuring: a `gh repo view` that printed `null`
584
+ // (or a bare string, or an array) used to throw a bare
585
+ // `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, whose
586
+ // message names neither the CLI nor the request — the exact identification
587
+ // failure this helper exists to fix.
588
+ const { nameWithOwner } = parseCliJson(
589
+ repoViewResult.stdout,
590
+ `gh ${repoViewArgs.join(' ')}`,
591
+ 'object',
592
+ );
454
593
 
455
594
  const data = await ghApi(
456
595
  `repos/${nameWithOwner}/commits/HEAD/check-runs`,
457
596
  repoRoot,
458
597
  deps,
598
+ 'object',
459
599
  );
460
600
 
461
601
  const checkRuns = data.check_runs;
@@ -516,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
516
656
  * or >= 2 remotes with no preference match) — a benign, measured absence
517
657
  * - Required CLI (glab / gh) not in PATH
518
658
  * - Any CLI invocation times out
519
- * - JSON parse failure on CLI output
520
659
  *
521
660
  * Also returns `null`, but with a `console.warn` trace, when the VCS-detection
522
- * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring) or when a
523
- * present CLI rejected its invocation. `null` alone cannot express "could not
524
- * read" see the outer catch and Step 1 for why the warn channel carries it.
661
+ * QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring), when a
662
+ * present CLI rejected its invocation, or when a CLI returned output this
663
+ * module could not parse (see {@link parseCliJson}). `null` alone cannot
664
+ * express "could not read" — see the outer catch and Step 1 for why the warn
665
+ * channel carries it.
666
+ *
667
+ * The unparseable-output case was listed above as SILENT until 2026-08-28.
668
+ * That was never the behaviour — it has always fallen through to the outer
669
+ * catch's warn branch (measured at 30940cb). The drift survived because the
670
+ * one test covering it asserted only the `null` and let the file-wide
671
+ * `console.warn` spy swallow the rest.
525
672
  *
526
673
  * @param {{
527
674
  * repoRoot?: string,
@@ -10,8 +10,9 @@
10
10
  * Behaviour (PRD §F1.3):
11
11
  * 1. Skip if `bootstrap.lock` is missing (repo not yet bootstrapped —
12
12
  * the bootstrap-gate handles that path).
13
- * 2. Skip if `sessions.jsonl` line count ≥ `silence-after-sessions`
14
- * (operator has already engaged at least once).
13
+ * 2. Skip if the `sessions.jsonl` DISTINCT-session count ≥
14
+ * `silence-after-sessions` identities, not lines (see `countSessions`);
15
+ * the operator has already engaged at least once.
15
16
  * 3. Skip if bootstrap age < `nudge-after-hours` (give the operator a
16
17
  * reasonable window after bootstrap before nudging).
17
18
  * 4. Emit otherwise, with a `markerPath` when the migration marker
@@ -34,6 +35,7 @@ import { stat, readFile, unlink } from 'node:fs/promises';
34
35
  import path from 'node:path';
35
36
 
36
37
  import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
38
+ import { countSessionsInJsonl } from './sessions-canonical.mjs';
37
39
 
38
40
  /** Milliseconds in one hour — exported for testability. */
39
41
  export const MS_PER_HOUR = 60 * 60 * 1000;
@@ -77,24 +79,31 @@ function buildBannerLines({ bootstrappedAt = null } = {}) {
77
79
  }
78
80
 
79
81
  /**
80
- * Best-effort line counter for NDJSON files. Returns 0 when the file is
81
- * missing OR empty OR unreadable — all three are equivalent for the
82
- * cold-start decision ("no sessions yet").
82
+ * Best-effort counter of DISTINCT physical sessions in an NDJSON ledger.
83
+ * Returns 0 when the file is missing OR empty OR unreadable — all three are
84
+ * equivalent for the cold-start decision ("no sessions yet").
85
+ *
86
+ * Counts IDENTITIES, not lines (#1167): `sessions.jsonl` is append-only, so one
87
+ * physical session can occupy two lines (a backfilled abandoned stub plus the
88
+ * authoritative record that supersedes it, or the systemic double-stub pair
89
+ * written by the two backfill writers). A raw line count therefore reports
90
+ * "the operator has already engaged" one session earlier than is true, and the
91
+ * cold-start nudge — one-shot per repo at the default threshold of 1 — is
92
+ * silently never emitted.
83
93
  *
84
94
  * Reads the full file because sessions.jsonl is small (1 line/session, ~2 KB
85
- * each — even 100 sessions = ~200 KB).
95
+ * each — even 100 sessions = ~200 KB). The async `readFile` is kept (the
96
+ * SessionStart hook's 5s budget must not block); only the collapse rules come
97
+ * from the shared canonical module.
86
98
  *
87
99
  * @param {string} filePath
88
100
  * @returns {Promise<number>}
89
101
  */
90
- async function countLines(filePath) {
102
+ async function countSessions(filePath) {
91
103
  try {
92
- const raw = await readFile(filePath, 'utf8');
93
- if (!raw) return 0;
94
- // Trailing newline must not inflate the count. split-then-filter handles
95
- // both Unix (\n) and Windows (\r\n) line endings.
96
- const lines = raw.split('\n').filter((l) => l.length > 0);
97
- return lines.length;
104
+ // Counting rule (blank/malformed lines, id-less records) lives in
105
+ // `countSessionsInJsonl`; only the async read is local.
106
+ return countSessionsInJsonl(await readFile(filePath, 'utf8'));
98
107
  } catch {
99
108
  return 0;
100
109
  }
@@ -166,7 +175,7 @@ export async function detectColdStart(opts = {}) {
166
175
 
167
176
  // PRD §F1.3 Ubiquitous: silence once the repo has ≥ N sessions. This is
168
177
  // the auto-silence path — banner is one-shot per repo (default N=1).
169
- const sessionCount = await countLines(sessionsPath);
178
+ const sessionCount = await countSessions(sessionsPath);
170
179
  if (sessionCount >= silenceAfterSessions) {
171
180
  return {
172
181
  shouldEmit: false,
@@ -648,12 +648,57 @@ export function tokenizeCommand(command) {
648
648
  return tokens;
649
649
  }
650
650
 
651
+ /**
652
+ * Shell RESERVED WORDS and grouping tokens that bash recognises ONLY in COMMAND
653
+ * POSITION — the first word of a statement (#1145). Everywhere else they are
654
+ * ordinary arguments (`echo do`, `git commit -m then`), which is exactly why the
655
+ * drop below is position-gated rather than a blanket text filter.
656
+ *
657
+ * Two classes, both dropped, for the same reason:
658
+ * - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
659
+ * `do`) are followed by a COMMAND. Dropping one moves verb resolution
660
+ * towards the real command — the identical safety argument WRAPPER_UNWRAP
661
+ * makes for a transparent wrapper: a dropped token is a reserved word, never
662
+ * an interpreter, so this cannot turn a match into a miss.
663
+ * - CLOSERS (`}`, `)`, `fi`, `done`, `esac`) end one. In command position they
664
+ * are the whole segment, which then filters out as empty.
665
+ *
666
+ * Deliberately NOT listed:
667
+ * - `for` / `select` / `case` / `in` / `function` — followed by a NAME or WORD,
668
+ * never a command. Dropping them would invent a verb (`for t in a` → `t`)
669
+ * where the truthful answer is "this segment has no command".
670
+ * - `time` — a reserved word AND an external binary, already classified as a
671
+ * transparent wrapper in WRAPPER_UNWRAP. One classification per token, in
672
+ * one table: a second entry here would fork the wrapper-vs-keyword decision
673
+ * the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
674
+ *
675
+ * NAMED CEILING (BV-004): only a STANDALONE token is dropped. bash requires
676
+ * whitespace around `{` / `}`, so brace groups are always covered; a subshell
677
+ * written WITHOUT the space (`(rm -rf /)`) lexes as the glued word `(rm` and is
678
+ * not reached — the pre-#1145 behaviour, unchanged. Peeling the paren off the
679
+ * token TEXT would mutate a token five consumers read positionally, which is a
680
+ * larger contract change than this defect justifies. Revisit if a glued-paren
681
+ * form shows up in a real overflow triage or guard-bypass measurement.
682
+ */
683
+ const COMMAND_POSITION_KEYWORDS = new Set([
684
+ '{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
685
+ '}', ')', 'fi', 'done', 'esac',
686
+ ]);
687
+
651
688
  /**
652
689
  * Split a tokenized command into chained segments on shell control operators
653
690
  * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
654
691
  * single-token operators split; an operator that arrived inside quotes stays
655
692
  * part of its segment.
656
693
  *
694
+ * A segment additionally has its COMMAND-POSITION reserved words stripped
695
+ * (#1145) — see {@link COMMAND_POSITION_KEYWORDS}. Without this the first token
696
+ * of a compound statement is `do` / `then` / `{` / `(` and NEVER the command,
697
+ * so every consumer that reads a segment head saw a keyword: measured against
698
+ * the live issue-budget guard with `max-per-session: 1, mode: strict`,
699
+ * `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
700
+ * were allowed with NO accounting at all, while the plain form denied at 1/1.
701
+ *
657
702
  * The newline separator is checked by its `operator` field as well as its text,
658
703
  * so the split survives a future change to that token's spelling. Because a
659
704
  * separator token is CONSUMED here, it can never reach a per-segment operand
@@ -678,6 +723,13 @@ function splitSegments(tokens) {
678
723
  current = [];
679
724
  continue;
680
725
  }
726
+ // Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
727
+ // the NEXT token becomes the segment head and is itself judged in command
728
+ // position (`do { rm …` peels both). A keyword anywhere else is an ordinary
729
+ // argument and is kept verbatim (#1145).
730
+ if (current.length === 0 && !tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text)) {
731
+ continue;
732
+ }
681
733
  current.push(tok);
682
734
  }
683
735
  segments.push(current);
@@ -763,6 +815,24 @@ export const WRAPPER_UNWRAP = new Map([
763
815
  }],
764
816
  ['command', {}],
765
817
  ['nohup', {}],
818
+ // `exec cmd args` REPLACES the shell with `cmd` — it delegates to a real verb
819
+ // and never executes a command STRING, so by the #982 classification it is a
820
+ // TRANSPARENT wrapper, not an interpreter (contrast `su -c`, which is in
821
+ // SHELL_EXEC_INTERPRETERS for exactly the opposite reason). `-a NAME` is the
822
+ // one value-taking flag; `-c` (clear environment) and `-l` are BOOLEANS here
823
+ // — despite the spelling, `exec -c` carries no payload, so it must NOT reach
824
+ // DASH_C_SHELLS, and it does not: that set is keyed on the RESOLVED verb.
825
+ // Redirect-only `exec > file` keeps working. Measured both namespaces: on the
826
+ // RAW segment the wrapper consumes `exec` and the redirect operator resolves
827
+ // as the verb (`>`, previously `exec`); on scope-gate's paren-peeled,
828
+ // redirect-stripped projection the segment exhausts in wrappers and yields
829
+ // `verb: null, index: -1` — the shape a bare `sudo` already produced. Neither
830
+ // is `rm`/`tee`/`sed`/`dd`, and a redirect target is read from the redirect
831
+ // token stream rather than from the verb, so detection is unaffected:
832
+ // measured `extractBashWriteTargets('exec > src/out.ts')` → `['src/out.ts']`.
833
+ ['exec', {
834
+ argFlags: new Set(['-a']),
835
+ }],
766
836
  // `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
767
837
  // `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
768
838
  // file will be overwritten"). With an empty spec the operand was read as the
@@ -31,6 +31,11 @@
31
31
  *
32
32
  * The two `**` markers are independent (`**key:` and `key:**` both match),
33
33
  * mirroring the tolerant #823 vault-integration regex.
34
+ *
35
+ * `matchBlockHeaderDetailed` (#1185) is a SEPARATE, additive matcher below for
36
+ * parsers that need what this contract deliberately rejects — an indented
37
+ * (nested) header and/or an inline value. It does not change the contract
38
+ * above; `matchBlockHeader`/`hasBlockHeader` keep exactly this behaviour.
34
39
  */
35
40
 
36
41
  /**
@@ -73,6 +78,56 @@ export function matchBlockHeader(line, key) {
73
78
  return blockHeaderRe(key).test(line);
74
79
  }
75
80
 
81
+ /**
82
+ * Build the indent-aware, inline-value-capturing block-header regex for a key.
83
+ * A strict SUPERSET of `blockHeaderRe`: it additionally matches an ARBITRARY
84
+ * leading indent (group 1) and an optional inline value trailing the colon
85
+ * (group 2) — the two things `matchBlockHeader` deliberately rejects (a
86
+ * nested sub-key, and a header carrying a value, per the module docblock).
87
+ *
88
+ * @param {string} key
89
+ * @returns {RegExp}
90
+ */
91
+ function detailedBlockHeaderRe(key) {
92
+ return new RegExp(
93
+ '^(\\s*)(?:-\\s+)?(?:\\*\\*)?' + escapeRegExp(key) + ':(?:\\*\\*)?(?:[ \\t]+(.*))?$'
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Indent + inline-value variant of `matchBlockHeader` (#1185). Additive —
99
+ * `matchBlockHeader`/`hasBlockHeader` are unchanged and every existing caller
100
+ * keeps its current behaviour untouched.
101
+ *
102
+ * Built for parsers whose header can be NESTED under a parent block (e.g.
103
+ * `health-endpoints:` one level under `ecosystem-health:`) and/or carry an
104
+ * INLINE value (`health-endpoints: [{name: …}]`) on the same line — exactly
105
+ * the two forms `matchBlockHeader` treats as "not a top-level block-opener".
106
+ * This is a separate, purpose-built matcher, not a relaxed replacement: a
107
+ * caller that only needs the boolean top-level check keeps using
108
+ * `matchBlockHeader`.
109
+ *
110
+ * The captured value is RAW text, never comment-stripped — `key: # note`
111
+ * reports `value: '# note'`, exactly as the pre-#1185 `health-endpoints.mjs`
112
+ * `HEADER_RE` did. A caller that needs comment semantics strips them itself
113
+ * (as `health-endpoints.mjs` already does for its block BODY via its own
114
+ * `stripComment()` — only the header line's raw capture moved here).
115
+ *
116
+ * @param {string} line — a single line (callers strip the trailing `\n`)
117
+ * @param {string} key — the literal block key
118
+ * @returns {{indent: number, value: string|null}|null} `null` on no match;
119
+ * `value` is `null` for a bare header (`key:`, optionally trailing
120
+ * whitespace only), else the trimmed text following the colon.
121
+ */
122
+ export function matchBlockHeaderDetailed(line, key) {
123
+ if (typeof line !== 'string' || typeof key !== 'string' || key === '') return null;
124
+ const m = line.match(detailedBlockHeaderRe(key));
125
+ if (!m) return null;
126
+ const indent = m[1].length;
127
+ const trimmed = (m[2] ?? '').trim();
128
+ return { indent, value: trimmed === '' ? null : trimmed };
129
+ }
130
+
76
131
  /**
77
132
  * Multiline presence variant: true when ANY line of `content` opens the
78
133
  * top-level block for `key`. For whole-file PRESENCE guards (e.g.
@@ -10,6 +10,11 @@ import { matchBlockHeader } from './block-header.mjs';
10
10
  * transcript. v1 is log+warn only (events.jsonl + stderr WARN); exit 2
11
11
  * (blocking) is reserved for a future hard-gate.
12
12
  *
13
+ * OFF by default (opt-in) — issue #567's original acceptance criterion. The
14
+ * #690 flip to ON (2026-06-25) was reverted 2026-09-02 (#1191) after fleet
15
+ * measurement: 6,946 `discovery_validator_violation` events accumulated in 18
16
+ * repos that never declared the block.
17
+ *
13
18
  * Returns `{ enabled }`.
14
19
  * Tolerant parser: malformed values silently fall back to defaults.
15
20
  *
@@ -21,14 +26,14 @@ import { matchBlockHeader } from './block-header.mjs';
21
26
  * Independent of the `## Session Config` section boundary.
22
27
  *
23
28
  * Defaults:
24
- * enabled: true
29
+ * enabled: false (opt-in)
25
30
  *
26
31
  * @param {string} content — full file contents
27
32
  * @returns {{ enabled: boolean }}
28
33
  */
29
34
  export function _parseDiscoveryValidator(content) {
30
35
  const defaults = {
31
- enabled: true,
36
+ enabled: false,
32
37
  };
33
38
 
34
39
  const lines = content.split(/\r?\n/);