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
@@ -227,6 +227,37 @@ function formatBytes(bytes) {
227
227
  return `${bytes} B`;
228
228
  }
229
229
 
230
+ /**
231
+ * Untracked is not the same as disposable. Three classes of untracked file
232
+ * under .orchestrator/ are load-bearing, so they are removed from the candidate
233
+ * set ENTIRELY rather than merely downgraded: a finding an operator must not
234
+ * act on is noise, and `fixable: true` routes it to the batch-work lane where
235
+ * acting on it is precisely what happens.
236
+ *
237
+ * `.backfilled-*.marker` — the TOCTOU idempotency guards written by
238
+ * scripts/lib/session-close-backfill.mjs (`markerName()`). The file's
239
+ * EXISTENCE is the whole state: delete one and the next backfill pass
240
+ * re-runs for that session id and duplicates its record. Age is the
241
+ * NORMAL condition of a marker whose session closed months ago.
242
+ * `*.jsonl` — append-only telemetry ledgers (audit, autopilot,
243
+ * vault-staleness, repair-candidates). They ARE the history every metric
244
+ * is computed from; mtime says when the ledger last grew, never whether
245
+ * its contents are still needed.
246
+ * `*.log` — append-only diagnostic logs (reconcile.rejected.log and
247
+ * siblings). Same argument as the ledgers.
248
+ *
249
+ * Everything else stays reportable: stray `*.json` scratch, `research/*.md`,
250
+ * abandoned run directories, and the 147 MB of test-run captures this check was
251
+ * built for.
252
+ *
253
+ * @param {string} name basename of the candidate file
254
+ * @returns {boolean}
255
+ */
256
+ function isRetainedArtifact(name) {
257
+ if (name.startsWith('.backfilled-') && name.endsWith('.marker')) return true;
258
+ return name.endsWith('.jsonl') || name.endsWith('.log');
259
+ }
260
+
230
261
  /**
231
262
  * H3 — Aged orchestrator artifacts.
232
263
  *
@@ -245,6 +276,12 @@ function formatBytes(bytes) {
245
276
  * their age is a sign of stability, not decay. Proposing them for
246
277
  * "pruning" is proposing to delete source.
247
278
  *
279
+ * 3. Untracked is not the same as disposable. Three classes are untracked BY
280
+ * DESIGN and load-bearing, and are excluded outright (see
281
+ * `isRetainedArtifact`). Measured in this repo 2026-08-24: 24 of the 25
282
+ * reported candidates were one of those three, so the finding was 96%
283
+ * unactionable while carrying `fixable: true`.
284
+ *
248
285
  * @param {string} repoRoot
249
286
  * @param {number} ageDays
250
287
  * @param {number} now
@@ -298,6 +335,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
298
335
  // walk, not the reportable subset.
299
336
  scanned++;
300
337
  if (tracked.has(full)) continue;
338
+ // Untracked BY DESIGN and load-bearing — never a pruning candidate.
339
+ if (isRetainedArtifact(e.name)) continue;
301
340
  try {
302
341
  const st = statSync(full);
303
342
  if (st.mtimeMs < cutoff) {
@@ -318,14 +357,15 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
318
357
  // (Phase 4), which routes fixable findings to "safe batch work" instead of
319
358
  // the operator Q&A — prose guidance to the coordinator, not an automatic
320
359
  // deletion run; `grep -rn "fixable" scripts/ skills/ hooks/` finds no other
321
- // reader than the `mechanical` count below. With tracked paths excluded the
322
- // claim is now true: every reported path is an untracked artifact.
360
+ // reader than the `mechanical` count below. With tracked paths AND the
361
+ // retained classes excluded the claim is now true: every reported path is an
362
+ // untracked artifact whose deletion loses nothing but disk.
323
363
  return {
324
364
  check: 'stale-artifacts',
325
365
  fixable: true,
326
366
  agedFiles: aged,
327
367
  agedBytes,
328
- message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning`,
368
+ message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning (idempotency markers + ledgers excluded)`,
329
369
  };
330
370
  }
331
371
 
@@ -29,15 +29,19 @@
29
29
  * failureContext.
30
30
  *
31
31
  * - `last-green-sha.txt` lives at `.orchestrator/runtime/last-green-sha.txt`
32
- * and is updated atomically after every successful gate. `changedFiles`
33
- * diffs against this file when present, falling back to `HEAD~1` otherwise.
34
- * The file is best-effortgit diff failures degrade to an empty array
32
+ * and is updated atomically after every successful gate. `changedFiles` is
33
+ * the UNION of the diff against this file (falling back to `HEAD~1`) and
34
+ * the UNCOMMITTED working tree (#1058) wave agents never commit, so the
35
+ * committed half alone reported nothing they did. Both halves are
36
+ * best-effort: a git failure in either degrades to an empty contribution
35
37
  * rather than blocking the gate.
36
38
  *
37
39
  * - `corrective_context` is read from `.orchestrator/current-session.json`
38
40
  * (written by `hooks/post-tool-failure-corrective-context.mjs`). Missing
39
- * file / parse failure → empty array. The most recent 5 entries are
40
- * forwarded to the fixer (older noise is dropped to keep prompts lean).
41
+ * file / parse failure → empty array; a file that provably belongs to a
42
+ * PEER session in the same working copy is treated as absent, with a stderr
43
+ * WARN (#1058). The most recent 5 entries are forwarded to the fixer (older
44
+ * noise is dropped to keep prompts lean).
41
45
  *
42
46
  * - Diagnostics bundle path: `.orchestrator/metrics/verification-failures/<ts>.json`.
43
47
  * Timestamp colons are replaced with `-` for filesystem portability.
@@ -63,6 +67,7 @@ import { fileURLToPath } from 'node:url';
63
67
  import { emitEvent, sessionAttribution } from './events.mjs';
64
68
  import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
65
69
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
70
+ import { readLock } from './session-lock.mjs';
66
71
 
67
72
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
68
73
 
@@ -254,15 +259,79 @@ function writeLastGreenSha(repoRoot) {
254
259
  }
255
260
 
256
261
  /**
257
- * List files changed since `ref` (or HEAD~1 if no ref). Best-effort: returns
258
- * empty array on any git failure.
262
+ * Parse `git status --porcelain -z` stdout into repo-root-relative paths.
263
+ *
264
+ * `-z` is not a convenience flag here — it is the only shape of this command
265
+ * whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
266
+ * carrying a space, a non-ASCII name, a literal `"` and a rename:
267
+ *
268
+ * ```
269
+ * git status --porcelain git status --porcelain -z
270
+ * ------------------------------ ---------------------------------
271
+ * M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
272
+ * M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
273
+ * ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
274
+ * R old.mjs -> new.mjs R new.mjs \0 old.mjs
275
+ * ```
276
+ *
277
+ * The non-`-z` form C-quotes any path containing a space, a `"` or — under the
278
+ * default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
279
+ * repairs only the non-ASCII third of that (measured: the space and the `"`
280
+ * stayed quoted). A field-splitting parser over the non-`-z` form fails three
281
+ * separate ways on one input — measured `awk '{print $2}'` output for the four
282
+ * lines above: `"scripts/lib/old` (truncated at the space), the undecoded
283
+ * `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
284
+ * line. `-z` emits every path verbatim, so there is no unquoting step to get
285
+ * wrong.
286
+ *
287
+ * Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
288
+ * `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
289
+ * per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
290
+ * `old.mjs`) as if it were a real path. Both paths are kept — a file moved OUT
291
+ * of `scripts/lib/` is as much a shared-lib touch as one moved in, and a fixer
292
+ * needs the old path to make sense of the new one. `R`/`C` are checked in BOTH
293
+ * status columns because git-status(1) documents `R `/`C ` (renamed/copied in
294
+ * index) as well as ` R`/` C` (renamed/copied in work tree).
295
+ *
296
+ * Untracked DIRECTORIES are not a case this parser has to handle: the caller
297
+ * passes `-uall`, which expands them to individual files (measured: `?? nd/`
298
+ * became `?? nd/a.mjs` + `?? nd/b.mjs`).
299
+ *
300
+ * @param {string} raw — raw stdout of `git status --porcelain -z …`.
301
+ * @returns {string[]} repo-root-relative paths, in git's emission order.
302
+ */
303
+ function parsePorcelainZ(raw) {
304
+ const fields = String(raw ?? '').split('\0');
305
+ const paths = [];
306
+ for (let i = 0; i < fields.length; i += 1) {
307
+ const entry = fields[i];
308
+ // `XY P` is the shortest well-formed entry. Anything shorter — including
309
+ // the empty trailing field `split` always produces — is not an entry
310
+ // header, and the `[2] === ' '` check rejects a stray original-path field
311
+ // that a malformed stream could leave unconsumed.
312
+ if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
313
+ const filePath = entry.slice(3);
314
+ if (filePath) paths.push(filePath);
315
+ const x = entry[0];
316
+ const y = entry[1];
317
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
318
+ i += 1;
319
+ const original = fields[i];
320
+ if (typeof original === 'string' && original) paths.push(original);
321
+ }
322
+ }
323
+ return paths;
324
+ }
325
+
326
+ /**
327
+ * Files that differ between `baseRef` and `HEAD` — the COMMITTED half of the
328
+ * change set. Best-effort: `[]` on any git failure.
259
329
  *
260
330
  * @param {string} repoRoot
261
- * @param {string|null} ref
331
+ * @param {string} baseRef
262
332
  * @returns {string[]}
263
333
  */
264
- function listChangedFiles(repoRoot, ref) {
265
- const baseRef = ref ?? 'HEAD~1';
334
+ function listCommittedChangedFiles(repoRoot, baseRef) {
266
335
  try {
267
336
  const result = spawnSync('git', ['diff', '--name-only', baseRef, 'HEAD'], {
268
337
  cwd: repoRoot,
@@ -279,9 +348,182 @@ function listChangedFiles(repoRoot, ref) {
279
348
  }
280
349
  }
281
350
 
351
+ /**
352
+ * Files modified, staged, or untracked in the WORKING TREE — the uncommitted
353
+ * half of the change set. Best-effort: `[]` on any git failure.
354
+ *
355
+ * `-uall` lists untracked files individually instead of collapsing a new
356
+ * directory to `dir/`; measured 2026-08-23 on this repo it costs nothing
357
+ * (`-uall` 30-33 ms vs `-unormal` 30-32 ms over 3 runs each), because ignored
358
+ * trees — `node_modules/`, `.orchestrator/runtime/` — are never walked.
359
+ *
360
+ * Ceiling (BV-004): the union below relies on `.gitignore` to keep the
361
+ * orchestrator's OWN runtime artifacts out of the result. In this repo
362
+ * `.orchestrator/runtime/`, `current-session.json` and `session.lock` are all
363
+ * ignored (`git check-ignore -v`, measured 2026-08-23), so the gate's
364
+ * `last-green-sha.txt` write cannot show up as a "changed file". A consumer
365
+ * repo that does NOT ignore `.orchestrator/` will see those artifacts here.
366
+ * Revisit if a caller needs a hard exclusion rather than a gitignore-derived
367
+ * one.
368
+ *
369
+ * @param {string} repoRoot
370
+ * @returns {string[]}
371
+ */
372
+ function listWorkingTreeChangedFiles(repoRoot) {
373
+ try {
374
+ const result = spawnSync('git', ['status', '--porcelain', '-z', '-uall'], {
375
+ cwd: repoRoot,
376
+ encoding: 'utf8',
377
+ stdio: ['ignore', 'pipe', 'pipe'],
378
+ maxBuffer: 16 * 1024 * 1024,
379
+ });
380
+ if (result.status !== 0 || !result.stdout) return [];
381
+ return parsePorcelainZ(result.stdout);
382
+ } catch {
383
+ return [];
384
+ }
385
+ }
386
+
387
+ /**
388
+ * List files this wave touched since `ref` (or HEAD~1 if no ref) — the UNION of
389
+ * the committed diff and the working tree.
390
+ *
391
+ * The union is the whole point (#1058). The committed half alone answered a
392
+ * question nobody asks: wave agents never commit — `.claude/rules/parallel-sessions.md`
393
+ * § PSA-007 forbids every git-write operation to a dispatched subagent, and the
394
+ * auto-commit that would have closed the gap does not exist (`skills/wave-executor/wave-loop.md`
395
+ * marks it "not yet implemented as of v3.10.0"; `scripts/lib/auto-commit.mjs` is
396
+ * absent, verified 2026-08-23). So at the moment {@link detectSharedLibTouch}
397
+ * runs, every file the wave produced is UNCOMMITTED, and a committed-only diff
398
+ * reports the previous coordinator commit's files or nothing at all. The FL-3
399
+ * auto-promotion to the Full Gate could therefore never fire on the wave's own
400
+ * work — structurally, not occasionally.
401
+ *
402
+ * Each half is independently best-effort: a failure in one contributes nothing
403
+ * and does not suppress the other. Both fail (no repo at all, unreadable index)
404
+ * → `[]`, preserving the documented never-throw / never-block contract.
405
+ *
406
+ * @param {string} repoRoot
407
+ * @param {string|null} ref
408
+ * @returns {string[]} de-duplicated, lexicographically sorted.
409
+ */
410
+ function listChangedFiles(repoRoot, ref) {
411
+ const baseRef = ref ?? 'HEAD~1';
412
+ const union = new Set([
413
+ ...listCommittedChangedFiles(repoRoot, baseRef),
414
+ ...listWorkingTreeChangedFiles(repoRoot),
415
+ ]);
416
+ return [...union].sort();
417
+ }
418
+
419
+ /**
420
+ * The id-space THIS process belongs to, for comparison against a repo-global
421
+ * file that any session in the working copy may have written.
422
+ *
423
+ * Two sources, both read rather than invented — this deliberately adds no third
424
+ * way of answering "who am I" (`scripts/lib/lock-reaper.mjs` takes the id from
425
+ * its caller; `scripts/lib/peer-discovery.mjs` reads `readLock()`):
426
+ *
427
+ * 1. `CLAUDE_CODE_SESSION_ID` — the only PER-PROCESS source, and therefore
428
+ * the only one a foreign session cannot spoof by writing a file. Measured
429
+ * 2026-08-23 inside a dispatched wave agent: present, and equal to the
430
+ * coordinator's `current-session.json` `session_id` (a child session
431
+ * inherits the parent's id, which is what makes it usable here).
432
+ * `scripts/lib/spiral-carryover.mjs` reads the same variable, under the
433
+ * same measured premise.
434
+ * 2. `session.lock` `session_id` / `semantic_session_id` — a repo-global
435
+ * FALLBACK for harnesses that export no session env var. Weaker on
436
+ * purpose: the lock is one more shared file in the same working copy, so
437
+ * it can name a peer rather than us. It is used only when (1) is absent,
438
+ * where the alternative is no check at all.
439
+ *
440
+ * @param {string} repoRoot
441
+ * @returns {Set<string>} possibly empty — an empty set means "identity
442
+ * unresolvable", which the classifier below treats as `unknown`, never as a
443
+ * mismatch.
444
+ */
445
+ function readOwnSessionIds(repoRoot) {
446
+ const ids = new Set();
447
+ // `.trim()` first: a whitespace-only env var is truthy and would otherwise
448
+ // enter the set as a phantom id (`development.md` § env-var whitespace trap).
449
+ const fromEnv = (process.env.CLAUDE_CODE_SESSION_ID || '').trim();
450
+ if (fromEnv) ids.add(fromEnv);
451
+ if (ids.size === 0) {
452
+ try {
453
+ const lock = readLock({ repoRoot });
454
+ for (const key of ['session_id', 'semantic_session_id']) {
455
+ const value = typeof lock?.[key] === 'string' ? lock[key].trim() : '';
456
+ if (value) ids.add(value);
457
+ }
458
+ } catch { /* readLock never throws, but the contract is not ours to trust */ }
459
+ }
460
+ return ids;
461
+ }
462
+
463
+ /**
464
+ * Decide whether a parsed `current-session.json` belongs to THIS session.
465
+ *
466
+ * Three outcomes, and the middle one is load-bearing:
467
+ *
468
+ * - `'foreign'` — the file names at least one session id, we know at least
469
+ * one of our own, and NONE of them match. This is the only verdict that
470
+ * discards data.
471
+ * - `'unknown'` — the file names no id, or we could not resolve our own.
472
+ * Ownership is unproven in BOTH directions, so the content is kept. A file
473
+ * without an id cannot be attributed to a peer either; discarding it would
474
+ * turn "cannot tell" into a silent feature-off on every harness that
475
+ * exports no session id.
476
+ * - `'own'` — an id matched.
477
+ *
478
+ * Rejecting only what is PROVABLY foreign is the point. The hazard being closed
479
+ * (#1058 second finding) is a peer session's corrective hints briefing this
480
+ * session's fixer agent, and that hazard always carries a concrete, mismatching
481
+ * id — the live file in this repo carried `conflict_with_session_id` from a
482
+ * genuine second session while it was measured.
483
+ *
484
+ * Residual gap this classifier CANNOT close, because it is a property of the
485
+ * writer: `hooks/post-tool-failure-corrective-context.mjs` appends to whatever
486
+ * `current-session.json` it finds without checking or stamping ownership, and
487
+ * `hooks/on-session-start.mjs` rewrites the identity block on session start.
488
+ * Two live sessions therefore interleave entries into ONE array under the
489
+ * NEWER session's id. This check catches the older session reading the newer
490
+ * session's file (verdict `foreign`); it cannot un-mix entries inside a file
491
+ * that legitimately carries our own id. Per-entry attribution belongs to the
492
+ * writer hook, not here.
493
+ *
494
+ * @param {unknown} parsed — parsed `current-session.json` content.
495
+ * @param {Set<string>} ownIds
496
+ * @returns {{ verdict: 'own'|'foreign'|'unknown', fileIds: string[] }}
497
+ */
498
+ function classifyCurrentSessionOwnership(parsed, ownIds) {
499
+ const fileIds = [];
500
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
501
+ for (const key of ['session_id', 'semantic_session_id']) {
502
+ const value = typeof parsed[key] === 'string' ? parsed[key].trim() : '';
503
+ if (value) fileIds.push(value);
504
+ }
505
+ }
506
+ if (fileIds.length === 0 || ownIds.size === 0) return { verdict: 'unknown', fileIds };
507
+ const matched = fileIds.some((id) => ownIds.has(id));
508
+ return { verdict: matched ? 'own' : 'foreign', fileIds };
509
+ }
510
+
282
511
  /**
283
512
  * Read `corrective_context` array from `.orchestrator/current-session.json`.
284
- * Returns the most-recent N entries. Empty array on missing file / parse failure.
513
+ * Returns the most-recent N entries. Empty array on missing file / parse failure
514
+ * — and, since #1058, on a file that provably belongs to ANOTHER session.
515
+ *
516
+ * `current-session.json` is repo-global: every session sharing this working
517
+ * copy writes to the same path. Without the ownership check below, a peer
518
+ * session's tool-failure hints were forwarded verbatim into THIS session's
519
+ * fixer-agent prompt, which is how a fixer gets briefed on a failure that never
520
+ * happened in the tree it is editing.
521
+ *
522
+ * A foreign file is treated as ABSENT — and said out loud on stderr. A silent
523
+ * discard and a silent misuse are the same error class: both leave the operator
524
+ * unable to tell that two sessions are contending for this working copy. The
525
+ * WARN is not de-duplicated; this function runs at most once per fixer dispatch
526
+ * plus once at abort, and each line marks a distinct decision point.
285
527
  *
286
528
  * @param {string} repoRoot
287
529
  * @returns {Array<object>}
@@ -292,6 +534,15 @@ function readCorrectiveContext(repoRoot) {
292
534
  if (!existsSync(p)) return [];
293
535
  const raw = readFileSync(p, 'utf8');
294
536
  const parsed = JSON.parse(raw);
537
+ const { verdict, fileIds } = classifyCurrentSessionOwnership(parsed, readOwnSessionIds(repoRoot));
538
+ if (verdict === 'foreign') {
539
+ process.stderr.write(
540
+ `⚠️ quality-gate: .orchestrator/current-session.json belongs to another session ` +
541
+ `(${fileIds.join(', ')}) — corrective context ignored. ` +
542
+ 'Another session is active in this working copy (PSA-001).\n',
543
+ );
544
+ return [];
545
+ }
295
546
  const arr = Array.isArray(parsed?.corrective_context) ? parsed.corrective_context : [];
296
547
  return arr.slice(-CORRECTIVE_CONTEXT_TAIL);
297
548
  } catch {
@@ -334,8 +585,15 @@ function writeDiagnosticsBundle(repoRoot, bundle) {
334
585
  * `scripts/lib/*`, `hooks/*`, or `.husky/*`, the blast radius is wider than the
335
586
  * agent could predict — auto-promote to Full Gate.
336
587
  *
337
- * Safe-default: any git failure (missing sinceRef, detached HEAD, no commits)
338
- * returns `{ touched: false, paths: [] }` so the gate never blocks a session.
588
+ * Since #1058 the underlying change set is the UNION of the committed diff and
589
+ * the UNCOMMITTED working tree. Without the second half this detector could
590
+ * never fire on the wave's own work: PSA-007 forbids a dispatched subagent
591
+ * every git-write, so at the moment this runs the wave's edits are uncommitted
592
+ * by construction — see {@link listChangedFiles}.
593
+ *
594
+ * Safe-default: a git failure on one half (missing sinceRef, detached HEAD, no
595
+ * commits) simply contributes nothing; both halves failing returns
596
+ * `{ touched: false, paths: [] }` so the gate never blocks a session.
339
597
  *
340
598
  * @param {object} opts
341
599
  * @param {string} opts.repoRoot — repo to diff against.
@@ -31,7 +31,7 @@
31
31
  import { dirname } from 'node:path';
32
32
 
33
33
  import { learningKeyOf } from '../learnings/kebab.mjs';
34
- import { deriveExpiresAt } from '../learnings/schema.mjs';
34
+ import { deriveExpiresAt, LEARNING_TYPE_REGISTRY } from '../learnings/schema.mjs';
35
35
 
36
36
  const DAY_MS = 86400 * 1000;
37
37
  const DESCRIPTION_MAX = 120;
@@ -81,6 +81,43 @@ const MIN_RULE_DAYS_DEFAULT = 7;
81
81
  */
82
82
  const HOST_CLASS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
83
83
 
84
+ /**
85
+ * Learning `type`s whose `host_class` is actually chip/OS-specific and may be
86
+ * copied through into the emitted rule's activation axis (#1090).
87
+ *
88
+ * DERIVED from `scripts/lib/learnings/schema.mjs`'s `LEARNING_TYPE_REGISTRY`:
89
+ * every type whose `hostScoped` flag is `true`. It was a hand-kept literal
90
+ * `new Set(['hardware-pattern'])` until #1151 — a SECOND type register beside
91
+ * the registry, and the two could only drift silently: a type made host-scoped
92
+ * in the registry would keep having its `host_class` dropped here, and a type
93
+ * removed there would keep gating rules by host. Same derivation pattern (and
94
+ * the same reason) as `CONVERT_TYPES` in `reconcile/eligibility.mjs` and
95
+ * `PROPOSAL_TYPES` in `memory-proposals/schema.mjs`.
96
+ *
97
+ * Before this allowlist, EVERY type's `host_class` was copied through
98
+ * unconditionally the moment it passed {@link HOST_CLASS_RE}. Measured against
99
+ * the live corpus (12/146 learnings carrying `host_class`, all
100
+ * `macos-arm64-m4pro`, on `anti-pattern`/`proven-pattern`/`recurring-issue` —
101
+ * NONE of them chip-specific content): a rule proposal could ship with
102
+ * `host-class` as its SOLE activation axis on a learning that has nothing to
103
+ * do with hardware, silently restricting an otherwise-general finding (a
104
+ * PNG-size anti-pattern, say) to one operator's one machine.
105
+ *
106
+ * `hardware-pattern` — the registry's only `hostScoped: true` entry today — is
107
+ * `ruleConvertible: false` there, so it can never reach `eligibility.mjs`'s
108
+ * CONVERT_TYPES allow-list and this set is currently INERT (no live record can
109
+ * exercise the "copied through" branch). It stays as forward-compat: the day a
110
+ * type is made BOTH host-scoped AND rule-convertible, the registry alone
111
+ * decides it is allowed to gate a rule by host — no edit to this file.
112
+ *
113
+ * @type {ReadonlySet<string>}
114
+ */
115
+ export const HOST_SPECIFIC_TYPES = new Set(
116
+ Object.entries(LEARNING_TYPE_REGISTRY)
117
+ .filter(([, meta]) => meta.hostScoped)
118
+ .map(([type]) => type)
119
+ );
120
+
84
121
  // Characters that make a `file_paths[]` entry unsafe to serialise as a glob.
85
122
  // The renderer emits each glob as ` - "<value>"`, so a control char (a newline
86
123
  // above all) breaks out of the block sequence into new top-level frontmatter
@@ -104,16 +141,22 @@ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
104
141
  // .claude/rules/proven-pattern-nul-byte-corruption...).
105
142
  const CONTROL_CHARS_TEST_RE = new RegExp(CONTROL_CHARS_RE.source);
106
143
 
107
- // Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
108
- // (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
109
- // stray '**' or '[ab]' entry (e.g. from an OLD learning record predating the
110
- // #900 C / schema.mjs argv-boundary guards) must never reach the renderer
111
- // verbatim, or it would produce an effectively always-on rule glob. Skipped
112
- // here as defense-in-depth even though the argv (memory-propose.mjs) and
113
- // schema (memory-proposals/schema.mjs) layers already reject these at write
114
- // time this emitter also processes learnings.jsonl entries that predate
115
- // those guards.
116
- const GLOB_METACHAR_RE = /[*?[\]{}]/;
144
+ // Glob metacharacters (issue #900-follow-up, Q3-MED; extended #1089). A
145
+ // top-level (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF
146
+ // below — so a stray '**', '[ab]', or a path containing a picomatch group
147
+ // (parens) must never reach the renderer verbatim, or it would either produce
148
+ // an effectively always-on rule glob OR — the #1089 defect — a glob that
149
+ // LOOKS scoped but can never match anything at all: `(` / `)` are not
150
+ // filtered here even though a directory segment like `app/(marketing)/page`
151
+ // (a Next.js route group) contains them, so `app/(marketing)/**` is emitted
152
+ // and picomatch's own group syntax then reads `(marketing)` as a capture
153
+ // group rather than a literal path segment — `picomatch.isMatch('app/
154
+ // (marketing)/page.tsx', 'app/(marketing)/**')` is `false`. A rule minted from
155
+ // such an entry loads, looks scoped, and never fires on the very path it was
156
+ // derived from. `!`, `@`, `+` are ALSO picomatch extglob prefixes, but only
157
+ // when immediately followed by `(` (`!(...)`, `@(...)`, `+(...)`) — bare `!`
158
+ // (e.g. a filename) is not itself unsafe and is left alone.
159
+ const GLOB_METACHAR_RE = /[*?[\]{}()]|[!@+](?=\()/;
117
160
 
118
161
  /**
119
162
  * Derive non-empty directory globs from a learning's `file_paths`.
@@ -121,12 +164,13 @@ const GLOB_METACHAR_RE = /[*?[\]{}]/;
121
164
  * For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
122
165
  * the file sits at the repo top level (`dirname` === '.'), emit the bare
123
166
  * basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
124
- * glob metacharacter (`* ? [ ] { }`) are skipped entirely see
125
- * {@link GLOB_METACHAR_RE} — as are entries carrying a control char or a quote
126
- * character, which would break out of the renderer's ` - "<glob>"` sequence
127
- * item into new top-level frontmatter keys (#1015; see
128
- * {@link CONTROL_CHARS_TEST_RE} / {@link UNSAFE_PATH_QUOTE_RE}). Results are
129
- * deduped, order-preserving on first occurrence.
167
+ * glob metacharacter (`* ? [ ] { } ( )`, or an extglob prefix `! @ +` directly
168
+ * before `(`) are skipped entirely — see {@link GLOB_METACHAR_RE} — as are
169
+ * entries carrying a control char or a quote character, which would break out
170
+ * of the renderer's ` - "<glob>"` sequence item into new top-level
171
+ * frontmatter keys (#1015; see {@link CONTROL_CHARS_TEST_RE} /
172
+ * {@link UNSAFE_PATH_QUOTE_RE}). Results are deduped, order-preserving on
173
+ * first occurrence.
130
174
  *
131
175
  * @param {string[]} filePaths
132
176
  * @returns {string[]}
@@ -262,7 +306,10 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
262
306
  * hostClass?: string,
263
307
  * }}
264
308
  * @throws {Error} when no activation axis can be produced (empty globs AND no
265
- * hostClass) — the never-always-on invariant.
309
+ * hostClass) — the never-always-on invariant. When `host_class` WAS present
310
+ * and valid but its `type` is not in {@link HOST_SPECIFIC_TYPES} (#1090), the
311
+ * message names that specifically rather than reporting the generic
312
+ * "no activation axis" reason.
266
313
  * @throws {Error} when the learning has no derivable `learning_key` (no usable
267
314
  * `type`, or no `title`/`subject` that slugs to a non-empty token) — a rule
268
315
  * whose key resolves to no learning is worse than no rule.
@@ -282,7 +329,16 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
282
329
  // silently emitting the rest of the rule from the same record would hide it.
283
330
  // The engine wraps each learning in its own try/catch, so this throw degrades
284
331
  // to ONE recorded rejection with an auditable reason, never a crashed run.
332
+ // Shape validation runs UNCONDITIONALLY, regardless of `type` — a malformed
333
+ // value is a corruption signal on its own and is rejected whether or not the
334
+ // type would ever be allowed to use it as an activation axis (see below).
285
335
  let hostClass;
336
+ // #1090: `host_class` is only meaningful as an activation axis for a type
337
+ // that is actually chip/OS-specific (see {@link HOST_SPECIFIC_TYPES}) — set
338
+ // when a VALID host_class exists but its type is not in that allow-list, so
339
+ // the never-always-on throw below can name the reason instead of reporting
340
+ // the generic "no activation axis" message.
341
+ let hostClassDroppedByType = false;
286
342
  if (typeof learning.host_class === 'string' && learning.host_class !== '') {
287
343
  if (!HOST_CLASS_RE.test(learning.host_class)) {
288
344
  throw new Error(
@@ -292,12 +348,24 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
292
348
  `defeating the never-always-on brandmauer and the expiry sweep. Expected ${HOST_CLASS_RE.source}.`,
293
349
  );
294
350
  }
295
- hostClass = learning.host_class;
351
+ if (HOST_SPECIFIC_TYPES.has(learning.type)) {
352
+ hostClass = learning.host_class;
353
+ } else {
354
+ hostClassDroppedByType = true;
355
+ }
296
356
  }
297
357
 
298
358
  // The brandmauer: an auto-generated rule must carry ≥1 activation axis. If we
299
359
  // could derive neither a glob nor a host-class, refuse — never emit always-on.
300
360
  if (globs.length === 0 && hostClass === undefined) {
361
+ if (hostClassDroppedByType) {
362
+ throw new Error(
363
+ `emitter: host_class ${JSON.stringify(learning.host_class)} is present and well-formed, but type ` +
364
+ `${JSON.stringify(learning.type)} is not host-specific (#1090 — host-class is only copied through for ` +
365
+ `${[...HOST_SPECIFIC_TYPES].join(', ')}) — refusing to emit always-on auto-generated rule ` +
366
+ '(never-always-on invariant); no other activation axis (globs) is available either.',
367
+ );
368
+ }
301
369
  throw new Error(
302
370
  'emitter: no activation axis (globs/host-class) — refusing to emit always-on auto-generated rule (never-always-on invariant)',
303
371
  );