session-orchestrator 5.1.0 → 5.2.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 (293) hide show
  1. package/.agents/skills/autopilot/SKILL.md +1 -0
  2. package/.agents/skills/bootstrap/SKILL.md +2 -0
  3. package/.agents/skills/brainstorm/SKILL.md +3 -0
  4. package/.agents/skills/close/SKILL.md +17 -0
  5. package/.agents/skills/debug/SKILL.md +2 -0
  6. package/.agents/skills/discovery/SKILL.md +2 -1
  7. package/.agents/skills/dispatcher/SKILL.md +2 -0
  8. package/.agents/skills/eli5/SKILL.md +2 -0
  9. package/.agents/skills/eval/SKILL.md +1 -0
  10. package/.agents/skills/evolve/SKILL.md +2 -1
  11. package/.agents/skills/go/SKILL.md +18 -0
  12. package/.agents/skills/grill/SKILL.md +2 -0
  13. package/.agents/skills/harness-audit/SKILL.md +16 -0
  14. package/.agents/skills/memory-cleanup/SKILL.md +1 -0
  15. package/.agents/skills/persona-panel/SKILL.md +1 -0
  16. package/.agents/skills/plan/SKILL.md +3 -1
  17. package/.agents/skills/portfolio/SKILL.md +17 -0
  18. package/.agents/skills/reconcile/SKILL.md +1 -0
  19. package/.agents/skills/release/SKILL.md +18 -0
  20. package/.agents/skills/repo-audit/SKILL.md +1 -0
  21. package/.agents/skills/spinout/SKILL.md +1 -0
  22. package/.agents/skills/sunset-review/SKILL.md +2 -0
  23. package/.agents/skills/test/SKILL.md +17 -0
  24. package/.agents/skills/ux-grill/SKILL.md +2 -0
  25. package/.claude-plugin/marketplace.json +1 -1
  26. package/.claude-plugin/plugin.json +1 -1
  27. package/.codex-plugin/plugin.json +1 -1
  28. package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
  29. package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
  30. package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
  31. package/.codex-plugin/skills/close/SKILL.md +3 -3
  32. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
  33. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/debug/SKILL.md +11 -4
  35. package/.codex-plugin/skills/discovery/SKILL.md +8 -4
  36. package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
  37. package/.codex-plugin/skills/eli5/SKILL.md +9 -4
  38. package/.codex-plugin/skills/eval/SKILL.md +9 -4
  39. package/.codex-plugin/skills/evolve/SKILL.md +9 -4
  40. package/.codex-plugin/skills/go/SKILL.md +3 -3
  41. package/.codex-plugin/skills/grill/SKILL.md +11 -4
  42. package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
  43. package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
  44. package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
  45. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
  46. package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
  47. package/.codex-plugin/skills/plan/SKILL.md +8 -4
  48. package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
  49. package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
  50. package/.codex-plugin/skills/release/SKILL.md +3 -3
  51. package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
  52. package/.codex-plugin/skills/spinout/SKILL.md +4 -4
  53. package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
  54. package/.codex-plugin/skills/test/SKILL.md +3 -3
  55. package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
  56. package/.cursor/commands/autopilot.md +4 -4
  57. package/.cursor/commands/bootstrap.md +5 -4
  58. package/.cursor/commands/brainstorm.md +5 -4
  59. package/.cursor/commands/close.md +4 -3
  60. package/.cursor/commands/convergence-monitoring.md +13 -0
  61. package/.cursor/commands/debug.md +4 -4
  62. package/.cursor/commands/discovery.md +4 -4
  63. package/.cursor/commands/dispatcher.md +4 -4
  64. package/.cursor/commands/eli5.md +4 -4
  65. package/.cursor/commands/eval.md +4 -4
  66. package/.cursor/commands/evolve.md +4 -4
  67. package/.cursor/commands/go.md +4 -3
  68. package/.cursor/commands/grill.md +4 -4
  69. package/.cursor/commands/harness-audit.md +3 -3
  70. package/.cursor/commands/memory-cleanup.md +4 -4
  71. package/.cursor/commands/npm-publish.md +13 -0
  72. package/.cursor/commands/persona-panel.md +4 -4
  73. package/.cursor/commands/plan.md +5 -4
  74. package/.cursor/commands/portfolio.md +3 -3
  75. package/.cursor/commands/reconcile.md +4 -4
  76. package/.cursor/commands/release.md +4 -3
  77. package/.cursor/commands/repo-audit.md +4 -4
  78. package/.cursor/commands/spinout.md +4 -4
  79. package/.cursor/commands/sunset-review.md +4 -4
  80. package/.cursor/commands/test.md +3 -3
  81. package/.cursor/commands/ux-grill.md +4 -4
  82. package/.cursor/rules/010-session-workflow.mdc +2 -2
  83. package/.cursor/skills/bootstrap/SKILL.md +1 -0
  84. package/.cursor/skills/close/SKILL.md +13 -0
  85. package/.cursor/skills/debug/SKILL.md +0 -1
  86. package/.cursor/skills/discovery/SKILL.md +0 -1
  87. package/.cursor/skills/dispatcher/SKILL.md +0 -1
  88. package/.cursor/skills/eli5/SKILL.md +0 -1
  89. package/.cursor/skills/evolve/SKILL.md +0 -1
  90. package/.cursor/skills/go/SKILL.md +13 -0
  91. package/.cursor/skills/grill/SKILL.md +0 -1
  92. package/.cursor/skills/harness-audit/SKILL.md +12 -0
  93. package/.cursor/skills/portfolio/SKILL.md +12 -0
  94. package/.cursor/skills/release/SKILL.md +13 -0
  95. package/.cursor/skills/repo-audit/SKILL.md +0 -1
  96. package/.cursor/skills/sunset-review/SKILL.md +0 -1
  97. package/.cursor/skills/test/SKILL.md +12 -0
  98. package/.cursor/skills/ux-grill/SKILL.md +0 -1
  99. package/.cursor-plugin/plugin.json +1 -1
  100. package/.orchestrator/policy/blocked-commands.json +1 -1
  101. package/AGENTS.md +1 -1
  102. package/CHANGELOG.md +61 -0
  103. package/README.md +11 -9
  104. package/commands/session.md +10 -0
  105. package/docs/ci-setup.md +53 -0
  106. package/docs/codex-setup.md +1 -1
  107. package/docs/components.md +11 -6
  108. package/docs/events-schema.md +4 -1
  109. package/docs/install.md +16 -0
  110. package/docs/persona-panel.md +1 -1
  111. package/docs/pi-setup.md +1 -1
  112. package/docs/rule-authoring.md +83 -14
  113. package/docs/scope-collision-guard.md +2 -0
  114. package/docs/session-config-reference.md +6 -4
  115. package/hooks/_lib/hook-import-set.json +46 -6
  116. package/hooks/_lib/subagent-paths.mjs +15 -0
  117. package/hooks/_lib/vcs-create-matcher.mjs +217 -62
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/on-session-end.mjs +14 -2
  121. package/hooks/on-stop.mjs +43 -1
  122. package/hooks/post-bash-write-verify.mjs +3 -0
  123. package/hooks/pre-auq-clarity.mjs +3 -0
  124. package/hooks/pre-bash-issue-budget.mjs +103 -17
  125. package/hooks/pre-task-scope-disjoint.mjs +152 -3
  126. package/hooks/skill-invocation-telemetry.mjs +2 -1
  127. package/package.json +2 -1
  128. package/pi/prompts/autopilot.md +3 -3
  129. package/pi/prompts/bootstrap.md +3 -3
  130. package/pi/prompts/brainstorm.md +3 -3
  131. package/pi/prompts/close.md +2 -2
  132. package/pi/prompts/convergence-monitoring.md +11 -0
  133. package/pi/prompts/debug.md +3 -3
  134. package/pi/prompts/discovery.md +3 -3
  135. package/pi/prompts/dispatcher.md +3 -3
  136. package/pi/prompts/eli5.md +3 -3
  137. package/pi/prompts/eval.md +3 -3
  138. package/pi/prompts/evolve.md +3 -3
  139. package/pi/prompts/go.md +2 -2
  140. package/pi/prompts/grill.md +3 -3
  141. package/pi/prompts/harness-audit.md +2 -3
  142. package/pi/prompts/memory-cleanup.md +3 -3
  143. package/pi/prompts/npm-publish.md +11 -0
  144. package/pi/prompts/persona-panel.md +3 -3
  145. package/pi/prompts/plan.md +3 -3
  146. package/pi/prompts/portfolio.md +2 -2
  147. package/pi/prompts/reconcile.md +3 -3
  148. package/pi/prompts/release.md +3 -3
  149. package/pi/prompts/repo-audit.md +3 -4
  150. package/pi/prompts/session.md +1 -1
  151. package/pi/prompts/spinout.md +3 -3
  152. package/pi/prompts/sunset-review.md +3 -3
  153. package/pi/prompts/templates-ack.md +1 -1
  154. package/pi/prompts/test.md +3 -3
  155. package/pi/prompts/ux-grill.md +3 -3
  156. package/scripts/archive-closed-prds.mjs +2 -2
  157. package/scripts/auq-audit.mjs +2 -3
  158. package/scripts/backfill-abandoned-sessions.mjs +57 -3
  159. package/scripts/backfill-evidence-digest.mjs +2 -1
  160. package/scripts/backfill-learnings-from-vault.mjs +2 -2
  161. package/scripts/check-package-manager.mjs +2 -2
  162. package/scripts/ci/assert-vitest-green.mjs +2 -1
  163. package/scripts/emit-session.mjs +2 -3
  164. package/scripts/export-hw-learnings.mjs +2 -1
  165. package/scripts/express-path.mjs +1 -1
  166. package/scripts/gc-stale-worktrees.mjs +2 -1
  167. package/scripts/generate-codex-skills.mjs +48 -4
  168. package/scripts/generate-cursor-adapter.mjs +173 -9
  169. package/scripts/generate-hook-import-set.mjs +12 -27
  170. package/scripts/generate-pi-prompts.mjs +183 -13
  171. package/scripts/github-protection-audit.mjs +2 -3
  172. package/scripts/lib/agent-frontmatter.mjs +23 -1
  173. package/scripts/lib/claude-md-budget-lint.mjs +2 -5
  174. package/scripts/lib/command-blocker.mjs +133 -5
  175. package/scripts/lib/config/drift-check.mjs +19 -0
  176. package/scripts/lib/convergence-monitor.mjs +2 -2
  177. package/scripts/lib/cursor-hook-bridge.mjs +2 -2
  178. package/scripts/lib/description-surface.mjs +2 -5
  179. package/scripts/lib/dispatcher/cli.mjs +2 -1
  180. package/scripts/lib/ecosystem-wizard.mjs +2 -1
  181. package/scripts/lib/fetch-baseline.mjs +3 -8
  182. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
  183. package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
  184. package/scripts/lib/instruction-budget-guard.mjs +186 -46
  185. package/scripts/lib/is-main-module.mjs +82 -0
  186. package/scripts/lib/locks/index.mjs +32 -25
  187. package/scripts/lib/maintenance-due-banner.mjs +69 -3
  188. package/scripts/lib/peer-discovery.mjs +2 -5
  189. package/scripts/lib/playwright-driver/runner.mjs +2 -1
  190. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
  191. package/scripts/lib/rules-sync.mjs +2 -5
  192. package/scripts/lib/scope-echo.mjs +392 -7
  193. package/scripts/lib/session-close-backfill.mjs +58 -6
  194. package/scripts/lib/state-md.mjs +84 -3
  195. package/scripts/lib/sunset/walker.mjs +31 -4
  196. package/scripts/lib/tests-src-ratio.mjs +2 -6
  197. package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
  198. package/scripts/lib/user-invocable-skills.mjs +185 -0
  199. package/scripts/lib/validate/check-banner-parity.mjs +2 -2
  200. package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
  201. package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
  202. package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
  203. package/scripts/lib/validate/check-entry-guard.mjs +366 -0
  204. package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
  205. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
  206. package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
  207. package/scripts/lib/validate/check-skill-links.mjs +27 -6
  208. package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
  209. package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
  210. package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
  211. package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
  212. package/scripts/lib/validate/check-unwired-features.mjs +91 -7
  213. package/scripts/lib/validate/check-validator-registration.mjs +2 -2
  214. package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
  215. package/scripts/lib/validate-vendored-rules.mjs +35 -9
  216. package/scripts/lib/wave-transcript-tail.mjs +2 -2
  217. package/scripts/lock-reaper.mjs +2 -1
  218. package/scripts/materialize-wave-scope.mjs +87 -4
  219. package/scripts/migrate-sessions-jsonl.mjs +2 -1
  220. package/scripts/migrate-vault-paths.mjs +2 -3
  221. package/scripts/release.mjs +80 -35
  222. package/scripts/relocate-vault-corpus.mjs +2 -3
  223. package/scripts/repair-invalid-sessions.mjs +2 -2
  224. package/scripts/session-shape.mjs +2 -2
  225. package/scripts/site-numbers.mjs +35 -11
  226. package/scripts/sweep-expired-rules.mjs +216 -0
  227. package/scripts/validate-plugin.mjs +9 -0
  228. package/scripts/vault-consolidate.mjs +2 -2
  229. package/scripts/vault-mirror.mjs +2 -3
  230. package/scripts/wave-scope-binding.mjs +2 -3
  231. package/skills/_shared/bootstrap-gate.md +1 -1
  232. package/skills/_shared/monitor-patterns.md +1 -1
  233. package/skills/_shared/research-evidence.md +53 -0
  234. package/skills/_shared/state-ownership.md +3 -0
  235. package/skills/autopilot/SKILL.md +58 -4
  236. package/skills/bootstrap/SKILL.md +51 -1
  237. package/skills/brainstorm/SKILL.md +16 -0
  238. package/skills/claude-md-drift-check/checker.mjs +49 -11
  239. package/{commands/close.md → skills/close/SKILL.md} +9 -3
  240. package/skills/debug/SKILL.md +10 -0
  241. package/skills/discovery/SKILL.md +24 -1
  242. package/skills/discovery/probes-session.md +2 -2
  243. package/skills/dispatcher/SKILL.md +38 -7
  244. package/skills/eli5/SKILL.md +11 -0
  245. package/skills/eval/SKILL.md +14 -0
  246. package/skills/evolve/SKILL.md +8 -1
  247. package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
  248. package/{commands/go.md → skills/go/SKILL.md} +9 -1
  249. package/skills/grill/SKILL.md +19 -0
  250. package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
  251. package/skills/hook-development/SKILL.md +46 -41
  252. package/skills/memory-cleanup/SKILL.md +7 -0
  253. package/skills/npm-publish/SKILL.md +1 -1
  254. package/skills/persona-panel/SKILL.md +56 -1
  255. package/skills/persona-panel/persona-format.md +1 -1
  256. package/skills/plan/SKILL.md +28 -1
  257. package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
  258. package/skills/reconcile/SKILL.md +10 -0
  259. package/{commands/release.md → skills/release/SKILL.md} +16 -2
  260. package/skills/repo-audit/SKILL.md +7 -0
  261. package/skills/session-end/plan-verification.md +2 -2
  262. package/skills/session-plan/SKILL.md +1 -1
  263. package/skills/session-start/SKILL.md +5 -4
  264. package/skills/session-start/phase-8-5-express-path.md +6 -6
  265. package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
  266. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
  267. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  268. package/skills/spinout/SKILL.md +12 -1
  269. package/skills/sunset-review/SKILL.md +13 -0
  270. package/{commands/test.md → skills/test/SKILL.md} +10 -4
  271. package/skills/ux-grill/SKILL.md +19 -1
  272. package/skills/wave-executor/SKILL.md +7 -4
  273. package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
  274. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  275. package/skills/wave-executor/references/wave-loop-review.md +17 -1
  276. package/commands/autopilot.md +0 -80
  277. package/commands/bootstrap.md +0 -56
  278. package/commands/brainstorm.md +0 -48
  279. package/commands/debug.md +0 -36
  280. package/commands/discovery.md +0 -32
  281. package/commands/dispatcher.md +0 -59
  282. package/commands/eli5.md +0 -33
  283. package/commands/eval.md +0 -28
  284. package/commands/evolve.md +0 -10
  285. package/commands/grill.md +0 -45
  286. package/commands/memory-cleanup.md +0 -26
  287. package/commands/persona-panel.md +0 -121
  288. package/commands/plan.md +0 -15
  289. package/commands/reconcile.md +0 -23
  290. package/commands/repo-audit.md +0 -24
  291. package/commands/spinout.md +0 -15
  292. package/commands/sunset-review.md +0 -27
  293. package/commands/ux-grill.md +0 -51
@@ -743,6 +743,31 @@ export { splitSegments as splitChainSegments };
743
743
 
744
744
  const ENV_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
745
745
 
746
+ /**
747
+ * Verbs that name a WRITE TARGET as a bare operand — no redirect operator, no
748
+ * wrapper flag (#1366). A sibling of WRAPPER_UNWRAP's `fileArgFlags`: that table
749
+ * answers "does this WRAPPER flag's operand name a file the wrapper writes?",
750
+ * this one "does this VERB's operand name a file the verb writes?".
751
+ *
752
+ * Spec fields:
753
+ * - prefix: the operand's literal prefix (`of=`), matched on a token AFTER
754
+ * the resolved verb.
755
+ * - mode: the default redirect mode the write is equivalent to.
756
+ * - appendRe: operands that turn the write into an APPEND. `dd` truncates by
757
+ * default (`conv=notrunc` / `oflag=append` do not), so reporting
758
+ * `truncate` unconditionally would over-report a rule class that
759
+ * deliberately permits `>>`.
760
+ *
761
+ * Deliberately narrow — one entry, measured. `tee`/`sponge` and in-process
762
+ * writers (`node -e fs.writeFileSync`) are NOT here: they are not redirect-shaped
763
+ * and belong to a different rule class (see the `redirect-harness-memory`
764
+ * rationale in .orchestrator/policy/blocked-commands.json). REVISIT TRIGGER: a
765
+ * second `<verb> <prefix>file` writer showing up in a transcript census.
766
+ */
767
+ const VERB_FILE_OPERANDS = new Map([
768
+ ['dd', { prefix: 'of=', mode: 'truncate', appendRe: /^(?:conv=.*\bnotrunc\b|oflag=.*\bappend\b)/ }],
769
+ ]);
770
+
746
771
  /**
747
772
  * Transparent process-wrapper table for verb resolution (#982), keyed by
748
773
  * basename. Each spec describes how to skip a wrapper's own options so the
@@ -946,15 +971,37 @@ function resolveCore(segment, unknownFlagsTakeValue) {
946
971
  if (spec.splitString
947
972
  && (text === '-S' || text === '--split-string'
948
973
  || text.startsWith('--split-string=') || text.startsWith('-S'))) {
974
+ // env(1) APPENDS the remaining argv to the split string: `env -S bash
975
+ // -c '…'` runs `bash -c '…'`, not `bash` (#1366). Collecting only the
976
+ // operand made the trailing `-c '…'` execute unseen — the segment then
977
+ // resolved to `verb: 'echo x > CLAUDE.md'` (the quoted payload read as
978
+ // the verb) and neither the match surface nor the redirect surface ever
979
+ // saw it. So the payload is the operand PLUS every remaining token, and
980
+ // the wrapper CONSUMES the segment: nothing is left to resolve as a
981
+ // verb, which is why this now yields `verb: null, index: -1`.
982
+ let operand = null;
949
983
  if (text.startsWith('--split-string=')) {
950
- payloads.push(text.slice('--split-string='.length));
984
+ operand = text.slice('--split-string='.length);
985
+ i++;
951
986
  } else if (text !== '-S' && text !== '--split-string') {
952
- payloads.push(text.slice(2)); // attached form: -S'string'
953
- } else if (i + 1 < segment.length) {
954
- payloads.push(segment[i + 1].text);
987
+ operand = text.slice(2); // attached form: -S'string'
955
988
  i++;
989
+ } else if (i + 1 < segment.length) {
990
+ operand = segment[i + 1].text;
991
+ i += 2;
992
+ } else {
993
+ i++; // `env -S` with no operand at all — nothing to collect
994
+ }
995
+ if (operand !== null) {
996
+ // Raw token texts, joined by one space. Quoting is NOT re-applied:
997
+ // the tokenizer already stripped it, and re-inventing it would be a
998
+ // second quoting grammar. The direction of that loss is safe — an
999
+ // over-wide payload can only make MORE text visible to the matcher
1000
+ // (fail-closed), never hide a redirect operator.
1001
+ const rest = segment.slice(i).map((t) => t.text);
1002
+ payloads.push(rest.length > 0 ? [operand, ...rest].join(' ') : operand);
1003
+ i = segment.length;
956
1004
  }
957
- i++;
958
1005
  continue;
959
1006
  }
960
1007
  if (spec.shellFlags && spec.shellFlags.has(text)) { sawShellFlag = true; i++; continue; }
@@ -1189,15 +1236,65 @@ function dedupedSegmentPayloads(segment, resolved) {
1189
1236
  if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1190
1237
  for (const p of dashCPayloads(segment, resolved.index)) payloadSet.add(p);
1191
1238
  }
1239
+ addArgJoinedPayload(segment, resolved.verb, resolved.index, payloadSet);
1192
1240
  if (resolved.alt) {
1193
1241
  for (const p of resolved.alt.payloads) payloadSet.add(p);
1194
1242
  if (resolved.alt.verb && DASH_C_SHELLS.has(resolved.alt.verb)) {
1195
1243
  for (const p of dashCPayloads(segment, resolved.alt.index)) payloadSet.add(p);
1196
1244
  }
1245
+ addArgJoinedPayload(segment, resolved.alt.verb, resolved.alt.index, payloadSet);
1197
1246
  }
1198
1247
  return [...payloadSet];
1199
1248
  }
1200
1249
 
1250
+ /**
1251
+ * Interpreters that JOIN their remaining arguments with a space and execute the
1252
+ * result — no `-c` flag, no wrapper option to key on (#1366).
1253
+ *
1254
+ * `eval` is the whole set today. It was already in SHELL_EXEC_INTERPRETERS, but
1255
+ * that set only powers the QUOTED-TOKEN match in matchSegments — it never fed
1256
+ * the payload traversal, so `eval 'echo x > CLAUDE.md'` carried a redirect no
1257
+ * redirect rule ever saw (`resolveSegmentVerb` reports `payloads: []` for it).
1258
+ *
1259
+ * NAMED CEILING (BV-004): only a LITERAL payload is reachable. `eval "$CMD"` and
1260
+ * `eval $(…)` do not exist as command text at hook time and stay in the existing
1261
+ * `unresolved` class (#641). REVISIT TRIGGER: a second arg-joining interpreter
1262
+ * showing up in a transcript census — extend the set, never special-case `eval`.
1263
+ */
1264
+ const ARG_JOINING_INTERPRETERS = new Set(['eval']);
1265
+
1266
+ /**
1267
+ * Add the joined post-verb argument text as a recursion payload when `verb` is
1268
+ * an arg-joining interpreter. No-op otherwise — every other caller shape keeps
1269
+ * the pre-#1366 payload set byte-identical.
1270
+ *
1271
+ * Quoting is not re-applied for the same reason as the `env -S` tail (see
1272
+ * resolveCore): the tokenizer stripped it, and a wider payload can only make
1273
+ * more text visible to the matcher, never hide an operator.
1274
+ *
1275
+ * @param {Array<{ text: string, quoted: boolean }>} segment
1276
+ * @param {string|null} verb
1277
+ * @param {number} index — the verb's token index (`-1` when unresolved)
1278
+ * @param {Set<string>} payloadSet — mutated in place
1279
+ */
1280
+ function addArgJoinedPayload(segment, verb, index, payloadSet) {
1281
+ if (!verb || index < 0 || !ARG_JOINING_INTERPRETERS.has(verb)) return;
1282
+ const words = [];
1283
+ for (let i = index + 1; i < segment.length; i++) {
1284
+ if (segment[i].redirect) {
1285
+ // A redirect belongs to the SEGMENT (`eval 'x' > f` redirects eval's own
1286
+ // stdout), never to the joined argv — the segment scan already reported
1287
+ // it, and re-reporting it through the payload would duplicate the entry.
1288
+ const end = redirectSpanEnd(segment, i);
1289
+ if (end > i) i = end;
1290
+ continue;
1291
+ }
1292
+ words.push(segment[i].text);
1293
+ }
1294
+ const joined = words.join(' ').trim();
1295
+ if (joined.length > 0) payloadSet.add(joined);
1296
+ }
1297
+
1201
1298
  /**
1202
1299
  * Match a blocked-pattern regex against tokenized chain segments — the shared
1203
1300
  * core of commandMatchesBlocked, recursion-capable for `-c` payloads (#982).
@@ -1347,6 +1444,37 @@ function collectRedirectTargets(segments, out, depth, budget) {
1347
1444
  out.push({ target: wa.value, mode: 'truncate', fd: null });
1348
1445
  }
1349
1446
 
1447
+ // A VERB can name its write target as a bare operand, with no redirect
1448
+ // operator and no wrapper flag: `dd of=CLAUDE.md` truncates CLAUDE.md while
1449
+ // `> CLAUDE.md` is denied (measured ALLOW pre-#1366 against the real
1450
+ // policy). Keyed on the RESOLVED verb, so `sudo dd of=X` is reached through
1451
+ // the existing wrapper unwrap; both readings contribute, as above.
1452
+ const seenVerbOperands = new Set();
1453
+ for (const reading of resolved.alt ? [resolved, resolved.alt] : [resolved]) {
1454
+ const spec = reading.verb ? VERB_FILE_OPERANDS.get(reading.verb) : null;
1455
+ if (!spec || reading.index < 0) continue;
1456
+ let operand = null;
1457
+ let append = false;
1458
+ for (let k = reading.index + 1; k < segment.length; k++) {
1459
+ const text = segment[k].text;
1460
+ if (segment[k].redirect) continue;
1461
+ if (text.startsWith(spec.prefix)) operand = text.slice(spec.prefix.length);
1462
+ else if (spec.appendRe?.test(text)) append = true;
1463
+ }
1464
+ if (operand === null || operand.length === 0) continue;
1465
+ const mode = append ? 'append' : spec.mode;
1466
+ const key = `${operand}:${mode}`;
1467
+ if (seenVerbOperands.has(key)) continue;
1468
+ seenVerbOperands.add(key);
1469
+ if (/[$`]/.test(operand)) {
1470
+ // Same fail-visible rule as a redirect operand (#983) — never guess at a
1471
+ // variable or a command substitution, never silently drop it either.
1472
+ out.push({ target: null, mode, fd: null, unresolved: true });
1473
+ continue;
1474
+ }
1475
+ out.push({ target: operand, mode, fd: null });
1476
+ }
1477
+
1350
1478
  // Both readings of an ambiguous unknown flag contribute payloads (#1000),
1351
1479
  // UNIONED and de-duped (HIGH-2). Walking parse A only let
1352
1480
  // `env -Q x bash -c 'echo pwned > CLAUDE.md'` — which resolves to the
@@ -5,6 +5,25 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
5
5
  * drift-check.mjs — Parser for the top-level `drift-check:` YAML block.
6
6
  *
7
7
  * Ported from config-yaml-parser.sh (v2).
8
+ *
9
+ * SCOPE NOTE — this file parses FLAGS ONLY; it implements no check. The
10
+ * `check-command-count` flag below toggles the `command-count` surface in
11
+ * `skills/claude-md-drift-check/checker.mjs`, which is where the counting lives.
12
+ *
13
+ * `command-count` STAYS on `ls commands/*.md | wc -l` on purpose. The checker is
14
+ * GENERIC over any consumer repo, where a slash command still IS a
15
+ * `commands/*.md` file. The 2026-09-16 command→skill fold (#1370) is a fact
16
+ * about THIS plugin — 24 command files folded into their same-named
17
+ * `skills/<name>/SKILL.md` behind an explicit `user-invocable: true` — so
18
+ * teaching the shared checker that union would mis-measure every repo that never
19
+ * folded. `scripts/site-numbers.mjs` `countCommands()` carries the union for this
20
+ * plugin's own tile, and names the divergence in its docblock.
21
+ *
22
+ * CONSEQUENCE for THIS repo, left deliberately visible rather than papered over:
23
+ * a prose claim of the shape "N commands" / "N slash commands" in a
24
+ * drift-check-scanned doc is compared against the `commands/` listing (2 after
25
+ * the fold), so such a claim must either be dropped or reworded off the
26
+ * `\b(\d+)\s+(?:\/)?commands?\b` pattern the checker matches.
8
27
  */
9
28
 
10
29
  /**
@@ -45,7 +45,7 @@
45
45
 
46
46
  import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
47
47
  import { join } from 'node:path';
48
- import { pathToFileURL } from 'node:url';
48
+ import { isMainModule } from './is-main-module.mjs';
49
49
 
50
50
  const DEFAULT_INTERVAL_S = 2;
51
51
  const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
@@ -502,7 +502,7 @@ function main() {
502
502
 
503
503
  // Run the tail loop only when executed as a script. Importing the module (for
504
504
  // unit tests over `classify`) must not parse vitest's argv and exit 1.
505
- if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
505
+ if (isMainModule(import.meta.url)) {
506
506
  main();
507
507
  }
508
508
 
@@ -18,10 +18,10 @@
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import path from 'node:path';
21
- import { pathToFileURL } from 'node:url';
22
21
 
23
22
  import { matcherMatches, readHookDecision } from './pi-hook-bridge.mjs';
24
23
  import { resolvePluginRoot } from './plugin-root.mjs';
24
+ import { isMainModule } from './is-main-module.mjs';
25
25
 
26
26
  export const CURSOR_TO_CANONICAL_EVENT = Object.freeze({
27
27
  sessionStart: 'SessionStart',
@@ -434,7 +434,7 @@ async function main() {
434
434
  process.stdout.write(`${JSON.stringify(toCursorHookOutput(eventName, result))}\n`);
435
435
  }
436
436
 
437
- const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
437
+ const isMain =isMainModule(import.meta.url);
438
438
  if (isMain) {
439
439
  main().catch((err) => {
440
440
  process.stderr.write(`cursor-hook-bridge: ${err instanceof Error ? err.message : err}\n`);
@@ -58,6 +58,7 @@
58
58
  import { readdirSync, readFileSync, statSync } from 'node:fs';
59
59
  import { fileURLToPath } from 'node:url';
60
60
  import { join, relative, resolve } from 'node:path';
61
+ import { isMainModule } from './is-main-module.mjs';
61
62
 
62
63
  /** Block-scalar indicator on the `description:` line: '>' (folded) or '|' (literal), optional chomping '+'/'-'. */
63
64
  const BLOCK_SCALAR_RE = /^([>|])([+-]?)\s*$/;
@@ -526,10 +527,6 @@ function main() {
526
527
  }
527
528
 
528
529
  const __filename = fileURLToPath(import.meta.url);
529
- const isMain =
530
- typeof process !== 'undefined' &&
531
- process.argv[1] !== null &&
532
- process.argv[1] !== undefined &&
533
- resolve(process.argv[1]) === resolve(__filename);
530
+ const isMain =isMainModule(import.meta.url);
534
531
 
535
532
  if (isMain) main();
@@ -32,6 +32,7 @@ import { warn } from '../common.mjs';
32
32
  import { acquire } from '../session-lock.mjs';
33
33
  import { enumerateCandidates, freeCandidates } from './enumerate.mjs';
34
34
  import { rankCandidates } from './rank.mjs';
35
+ import { isMainModule } from '../is-main-module.mjs';
35
36
 
36
37
  const VERSION = '1.0.0';
37
38
 
@@ -247,7 +248,7 @@ export async function main(argv) {
247
248
  }
248
249
 
249
250
  // Run main only when executed directly, never on import.
250
- if (import.meta.url === `file://${process.argv[1]}`) {
251
+ if (isMainModule(import.meta.url)) {
251
252
  main(process.argv.slice(2))
252
253
  .then((code) => process.exit(code))
253
254
  .catch((err) => {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { runEcosystemWizard } from './ecosystem-wizard/wizard-prompt.mjs';
10
+ import { isMainModule } from './is-main-module.mjs';
10
11
 
11
12
  export { detectCiProvider } from './ecosystem-wizard/ci-detector.mjs';
12
13
  export {
@@ -31,7 +32,7 @@ export { runEcosystemWizard } from './ecosystem-wizard/wizard-prompt.mjs';
31
32
  // CLI entry
32
33
  // ---------------------------------------------------------------------------
33
34
 
34
- if (process.argv[1] && process.argv[1].endsWith('ecosystem-wizard.mjs')) {
35
+ if (isMainModule(import.meta.url)) {
35
36
  const args = process.argv.slice(2);
36
37
  const repoRootIdx = args.indexOf('--repo-root');
37
38
  const dryRun = args.includes('--dry-run');
@@ -30,6 +30,8 @@ import { promises as fs } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { execSync } from 'node:child_process';
32
32
 
33
+ import { isMainModule } from './is-main-module.mjs';
34
+
33
35
  // ---------------------------------------------------------------------------
34
36
  // Configuration defaults (mirrors the .sh defaults)
35
37
  // ---------------------------------------------------------------------------
@@ -296,14 +298,7 @@ async function _cliMain() {
296
298
  process.exit(3);
297
299
  }
298
300
 
299
- // Detect CLI invocation: import.meta.url matches the argv[1] path
300
- const isMain = process.argv[1] && (
301
- process.argv[1] === new URL(import.meta.url).pathname ||
302
- // Handle invocation without file:// prefix
303
- process.argv[1].endsWith('fetch-baseline.mjs')
304
- );
305
-
306
- if (isMain) {
301
+ if (isMainModule(import.meta.url)) {
307
302
  _cliMain().catch((err) => {
308
303
  process.stderr.write(`FATAL: ${err instanceof Error ? err.message : String(err)}\n`);
309
304
  process.exit(3);
@@ -24,6 +24,7 @@ import { promisify } from 'node:util';
24
24
 
25
25
  import { detectVcsForRepo, discoverVaultRepos } from '../gitlab-portfolio/vcs-detect.mjs';
26
26
  import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
27
+ import { isMainModule } from '../is-main-module.mjs';
27
28
 
28
29
  const realExecFile = promisify(execFileCb);
29
30
 
@@ -455,7 +456,7 @@ export async function main(argv, deps = {}) {
455
456
 
456
457
  // ── CLI guard — prevent process.exit during test-time imports ─────────────────
457
458
 
458
- if (import.meta.url === `file://${process.argv[1]}`) {
459
+ if (isMainModule(import.meta.url)) {
459
460
  main(process.argv.slice(2))
460
461
  .then((result) => {
461
462
  process.exit(result.exitCode);
@@ -36,6 +36,7 @@ import { expandTilde } from '../common.mjs';
36
36
  import { discoverVaultRepos } from './vcs-detect.mjs';
37
37
  import { fetchIssuesMultiRepo, summarizeRepo } from './aggregator.mjs';
38
38
  import { renderPortfolio, writePortfolio } from './markdown-writer.mjs';
39
+ import { isMainModule } from '../is-main-module.mjs';
39
40
 
40
41
  // ── Helpers ───────────────────────────────────────────────────────────────────
41
42
 
@@ -406,7 +407,7 @@ EXIT CODES
406
407
 
407
408
  // ── CLI guard — prevent process.exit during test-time imports ─────────────────
408
409
 
409
- if (import.meta.url === `file://${process.argv[1]}`) {
410
+ if (isMainModule(import.meta.url)) {
410
411
  main(process.argv.slice(2)).then((result) => {
411
412
  process.exit(result.exitCode);
412
413
  }).catch((err) => {
@@ -255,17 +255,17 @@ export const DEFAULT_BYTE_CEILING = 121000;
255
255
  * → rulesDir extracted from `c73c094f`: { bytes: 76114, files: 8 }
256
256
  * → rulesDir extracted from `e4674109`: { bytes: 89763, files: 43 }
257
257
  *
258
- * The CONCLUSION stands, on a wider base: firing rate is 0 of 3, not 0 of 2 —
259
- * the guard would not have fired at any of the three states this repo has
260
- * recorded. What does NOT stand is the headroom claim it rested on. The live
261
- * corpus is **2,427 B** below the historical peak, not 13,649 B: a single
262
- * /reconcile wave of the size this one just absorbed puts the generated corpus
263
- * back at its worst recorded state, and the ceiling still would not notice.
264
- * "Decorative" is therefore an understatement of the gap, not an overstatement
265
- * which strengthens the case for the operator decision this block defers,
266
- * and changes none of its terms. Still tracked rather than silently patched:
267
- * raising OR lowering a threshold inside a population fix is exactly the
268
- * conflation this comment exists to end.
258
+ * HISTORY as of 2026-09-16 every number in this paragraph described the
259
+ * 124,000 ceiling, which no longer exists on this axis. It read, correctly for
260
+ * 2026-09-11: firing rate 0 of 3, not 0 of 2; the generated corpus then sat
261
+ * **2,427 B** below the peak known at that time (87,336 of 89,763 B), not
262
+ * 13,649 B, so one /reconcile wave of the size that session absorbed would have
263
+ * restored its worst recorded state with the ceiling still silent. Superseded
264
+ * on BOTH terms by the RE-CALIBRATED block below (ceiling 95,000, true peak
265
+ * 99,774 B, live distance to the peak 29,833 B measured 2026-09-16). Kept
266
+ * because it records the rule that produced the eventual fix: raising OR
267
+ * lowering a threshold inside a population fix is exactly the conflation this
268
+ * comment exists to end.
269
269
  *
270
270
  * FIRING-RATE AUDIT — widened from 3 states to 89 (#1308, 2026-09-11T17:26Z,
271
271
  * clean tree @ `4a49adc6`). HR-105 forbids a rule nothing records; "0 of 3"
@@ -273,35 +273,63 @@ export const DEFAULT_BYTE_CEILING = 121000;
273
273
  * guard was replayed over EVERY commit that touched `.claude/rules/`:
274
274
  * `git log --format=%h -- .claude/rules/` → 89 commits, each extracted with
275
275
  * `git archive <sha> .claude/rules | tar -x -C <tmp>` and fed to
276
- * `computeInstructionBudget({rulesDir})`:
276
+ * `computeInstructionBudget({rulesDir})`. Firing rates only — the headroom
277
+ * annotations this table used to carry are deliberately gone, because an
278
+ * absolute headroom rots between two edits of the corpus; read the live one
279
+ * from the axis itself (command in the RE-MEASURED block below):
277
280
  *
278
281
  * directive axis (480) → fires 4/89 = 4.5 % rare, healthy
279
- * byte axis (121,000) → fires 0/89 = 0.0 % live: 586 B headroom
280
- * generated axis (124,000) → fires 0/89 = 0.0 % peak ever 89,763 B
281
- * pathScoped axis (124,000) → fires 4/89 = 4.5 % falsifiable
282
- *
283
- * "Silently broken" is now RULED OUT: `pathScoped` runs the same code path off
284
- * the same walk and fires on 4 of the same 89 states. What remains is the
285
- * calibration gap — the all-time peak of this population is 89,763 B, i.e.
286
- * 72.4 % of the ceiling, so no state this repo has ever recorded could have
287
- * breached it, and none plausibly will before the corpus grows ~38 %.
288
- * A falsifiable ceiling for this axis would sit just above the recorded peak
289
- * (≈ 92,000 B, peak + 2.5 %); this module does NOT move it, because
290
- * `.claude/rules/development.md` § Guard & Threshold Design makes re-aiming a
291
- * threshold an operator decision, separate from measuring it. Revisit trigger:
292
- * re-run the 89-state replay above whenever the generated corpus passes
293
- * 92,000 B, or when this axis first fires (then it is calibrated, not
294
- * decorative).
295
- *
296
- * That trigger FIRED: the generated corpus stood at 99,774 B at `c16fb518`
297
- * (2026-09-12), past 92,000. It was answered by consolidation rather than by
298
- * a replay of this axis — the 8 generated files were merged down (prose only,
299
- * provenance unchanged) to 75,130 B / 8 files on the 2026-09-12 working tree
300
- * (#1316), back under the 92,000 mark. This ceiling is unchanged and the
301
- * 89-state replay of THIS axis was not re-run; the trigger stays armed for the
302
- * next pass of 92,000 B.
282
+ * byte axis (121,000) → fires 0/89 = 0.0 %
283
+ * generated axis (124,000) → fires 0/91 = 0.0 % superseded, see below
284
+ * pathScoped axis (124,000) → fires 5/91 = 5.5 % → calibrated
285
+ *
286
+ * RE-CALIBRATED 2026-09-16 (#1308). The replay was re-run over the FULL
287
+ * recorded history of this directory, not the 89-state subset:
288
+ *
289
+ * `git log --format=%h -- ./.claude/rules/` 91 states; each materialized
290
+ * with `git archive <sha> .claude/rules | tar -x -C <tmp>` and fed to
291
+ * `computeInstructionBudget({ rulesDir })`.
292
+ *
293
+ * True peak of the generated axis: **99,774 B @ `2ae08fe0`** — which CORRECTS
294
+ * the "peak ever 89,763 B" this docblock carried until now (that number came
295
+ * from a single replayed commit, not from the population). Median state:
296
+ * 4,802 B. Firing table over the same 91 states:
297
+ *
298
+ * 124,000 → 0/91 = 0.0 % (the standing, unfalsifiable value)
299
+ * 102,268 (peak+2.5%)→ 0/91 = 0.0 % (a ceiling above the peak never fires)
300
+ * 95,000 1/91 = 1.1 % ← chosen
301
+ * 88,000 → 3/91 = 3.3 %
302
+ * 80,000 → 5/91 = 5.5 %
303
+ *
304
+ * Why 95,000: it FIRES on recorded history (HR-105 a rule you cannot falsify
305
+ * is not a rule), at 1.1 % it sits in HR-101's rare band (a class above ~10 %
306
+ * is a broken instrument), and it stays silent on the corpus it lands on, so it
307
+ * does not go red before the #1367 consolidation has been committed. "Peak +
308
+ * 2.5 %" is REJECTED here for the reason the old text recommended it: above the
309
+ * peak, the axis is decorative by construction.
310
+ *
311
+ * RE-MEASURED 2026-09-16 @ `ca214376`, after the #1367 consolidation landed in
312
+ * the working tree (the state this ceiling was calibrated for is the state it
313
+ * now judges):
314
+ *
315
+ * node --input-type=module -e "const m = await import('./scripts/lib/instruction-budget-guard.mjs'); \
316
+ * console.log(JSON.stringify(m.computeInstructionBudget({ repoRoot: process.cwd() }).bySurface))"
317
+ * → generated { bytes: 69941, files: 7 } ×1.36 under 95,000
318
+ * pathScoped { bytes: 109932, files: 10 }
319
+ *
320
+ * Read the headroom off that command, never off a sentence in this block: the
321
+ * two numbers a verdict needs are the AXIS and its CEILING, and the distance
322
+ * between them changes with every rule edit. For the record at that SHA the
323
+ * generated axis sat 25,059 B under its ceiling and 29,833 B under the 99,774 B
324
+ * peak — i.e. one /reconcile wave the size of the largest recorded one would
325
+ * still not reach the peak, but a repeat of the growth that produced it would
326
+ * cross 95,000 first. That is the intended, rare signal: a cue to consolidate.
327
+ *
328
+ * BV-004 revisit trigger: re-run the 91-state replay when the generated corpus
329
+ * passes 90,000 B, or on this ceiling's FIRST fire — at which point it is
330
+ * calibrated by a real event rather than by a replay.
303
331
  */
304
- export const DEFAULT_GENERATED_BYTE_CEILING = 124000;
332
+ export const DEFAULT_GENERATED_BYTE_CEILING = 95000;
305
333
 
306
334
  /**
307
335
  * Default byte ceiling for the PATH-SCOPED rule surface — the fourth axis,
@@ -353,11 +381,14 @@ export const DEFAULT_GENERATED_BYTE_CEILING = 124000;
353
381
  * working tree (2026-09-11): 134,969 B / 11 files → FIRES
354
382
  *
355
383
  * Firing rate 2 of 3, falsifiable in both directions — the condition
356
- * {@link DEFAULT_GENERATED_BYTE_CEILING} does NOT currently meet (0 of 3).
357
- * Confirmed on a wider base (#1308, 2026-09-11T17:26Z @ `4a49adc6`): replayed
358
- * over all 89 commits that touched `.claude/rules/`, this axis fires 4/89
359
- * (4.5 %) against the generated axis's 0/89 — inside HR-101's rare band, and
360
- * falsifiable, which is exactly the property the sibling ceiling lacks.
384
+ * {@link DEFAULT_GENERATED_BYTE_CEILING} did not meet while it stood at
385
+ * 124,000 (0 of 3). Confirmed on a wider base (#1308, 2026-09-11T17:26Z @
386
+ * `4a49adc6`): replayed over all 89 commits that touched `.claude/rules/`, this
387
+ * axis fires 4/89 (4.5 %) — inside HR-101's rare band, and falsifiable.
388
+ * SUPERSEDED as a CONTRAST 2026-09-16: the sibling ceiling was re-calibrated to
389
+ * 95,000 in the same #1308 replay and now fires 1/91 (1.1 %) on recorded
390
+ * history, so "the property the sibling ceiling lacks" is no longer true of it —
391
+ * both axes are falsifiable, and this one is simply the looser of the two.
361
392
  * This is also why it is not re-derived upward off the live number: a ceiling
362
393
  * placed above 134,969 would be silent on all three states, i.e. the same
363
394
  * unfalsifiable shape, obtained by the threshold-patch move
@@ -385,9 +416,14 @@ export const DEFAULT_GENERATED_BYTE_CEILING = 124000;
385
416
  * the threshold-patch move `development.md` § Guard & Threshold Design
386
417
  * forbids.
387
418
  *
388
- * Headroom is 1,237 B. The next `/reconcile` rule (~2.5 KB written
389
- * standalone) trips this axis unless it is absorbed into a thematic file in
390
- * the SAME write step an obligation written in the /reconcile-overshoot
419
+ * Headroom moves with every rule edit, so it is measured, never quoted from
420
+ * here: `computeInstructionBudget({ repoRoot }).bySurface.pathScoped` against
421
+ * this constant. At 2026-09-16 @ `ca214376` that was 109,932 B / 10 files —
422
+ * 14,068 B under the ceiling, after the #1367 consolidation; on 2026-09-12 it
423
+ * was 1,237 B, which is how narrow this axis can get. The next `/reconcile`
424
+ * rule (~2.5 KB written standalone) trips it from a margin like that one
425
+ * unless it is absorbed into a thematic file in the SAME write step — an
426
+ * obligation written in the /reconcile-overshoot
391
427
  * learning of `.claude/rules/measurement-discipline.md` and checked by the
392
428
  * reconcile writer's budget pre-flight (`scripts/lib/reconcile/writer.mjs`).
393
429
  * A breach sets `overBudget` AND turns `tests/rules/receiving-review.test.mjs`
@@ -749,7 +785,7 @@ function sumBytes(entries) {
749
785
  * @param {Record<string, unknown>} meta
750
786
  * @returns {boolean}
751
787
  */
752
- function isMachineGeneratedRule(meta) {
788
+ export function isMachineGeneratedRule(meta) {
753
789
  if (!meta || typeof meta !== 'object') return false;
754
790
  return (
755
791
  meta['auto-generated'] === true ||
@@ -758,6 +794,110 @@ function isMachineGeneratedRule(meta) {
758
794
  );
759
795
  }
760
796
 
797
+ /** One day in milliseconds — local to this module, as in every other lib here. */
798
+ const MS_PER_DAY = 86_400_000;
799
+
800
+ /**
801
+ * Whole days from `now` until this rule's `expires-at`, or `null` when the rule
802
+ * declares no expiry or an unparseable one.
803
+ *
804
+ * Negative ⇒ already expired. The comparison basis is deliberately the SAME one
805
+ * `rule-loader.mjs` `applyGates` uses — `Date.parse(expires-at) < now`, i.e. a
806
+ * bare `YYYY-MM-DD` is UTC midnight — so "expired" here means exactly "the
807
+ * loader already filters it out of every wave's rule set", never a second
808
+ * definition of the same word. `Math.floor` therefore returns `-1` for a rule
809
+ * that expired at midnight today: past, but by less than a day.
810
+ *
811
+ * An unparseable date returns `null` (fail-OPEN, matching the loader, which
812
+ * warns and ignores the expiry rather than excluding the file) — the malformed
813
+ * value is a separate finding, not an expiry verdict.
814
+ *
815
+ * @param {{ meta?: Record<string, unknown>, now?: Date|number|string }} [opts]
816
+ * @returns {number|null}
817
+ */
818
+ export function daysUntilGeneratedRuleExpiry(opts = {}) {
819
+ const meta = opts.meta;
820
+ if (!meta || typeof meta !== 'object') return null;
821
+ if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return null;
822
+ const expiresAt = Date.parse(String(meta['expires-at']));
823
+ if (!Number.isFinite(expiresAt)) return null;
824
+
825
+ const raw = opts.now;
826
+ let now;
827
+ if (raw instanceof Date) now = raw.getTime();
828
+ else if (typeof raw === 'number' && Number.isFinite(raw)) now = raw;
829
+ else if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) now = Date.parse(raw);
830
+ else now = Date.now();
831
+
832
+ return Math.floor((expiresAt - now) / MS_PER_DAY);
833
+ }
834
+
835
+ /**
836
+ * Is this generated rule's `expires-at` already in the past?
837
+ *
838
+ * The defect it names (#1372): `rule-loader.mjs` filters an expired rule out at
839
+ * READ time, but nothing DELETES it — so the file keeps being counted by every
840
+ * byte ceiling above and keeps being loaded verbatim by Claude Code, which has
841
+ * no notion of `expires-at`.
842
+ *
843
+ * @param {{ meta?: Record<string, unknown>, now?: Date|number|string }} [opts]
844
+ * @returns {boolean}
845
+ */
846
+ export function isExpiredGeneratedRule(opts = {}) {
847
+ const days = daysUntilGeneratedRuleExpiry(opts);
848
+ return days !== null && days < 0;
849
+ }
850
+
851
+ /**
852
+ * Enumerate the machine-generated rule files in `rulesDir`.
853
+ *
854
+ * Same population, same filter and same frontmatter parser as
855
+ * {@link measureRuleCorpora}'s `generated` surface — that is the point of
856
+ * exporting it: a consumer that needs the FILES rather than the BYTES (the
857
+ * expiry test, the session-start maintenance probe) must not mirror the
858
+ * predicate, because a mirror agrees only until one side is edited.
859
+ *
860
+ * Three-state, like every reader in the banner family: `ok: false` means the
861
+ * directory exists but could not be enumerated, which a caller must record as
862
+ * "undeterminable" and never as "no generated rules". A MISSING directory is
863
+ * `ok: true` with an empty list — a repo without `.claude/rules/` genuinely has
864
+ * none.
865
+ *
866
+ * @param {{ rulesDir?: string, repoRoot?: string }} [opts]
867
+ * @returns {{ ok: boolean, rules: Array<{ file: string, meta: Record<string, unknown>, expiresAt: string|null }> }}
868
+ */
869
+ export function listMachineGeneratedRules(opts = {}) {
870
+ const dir =
871
+ typeof opts.rulesDir === 'string' && opts.rulesDir.length > 0
872
+ ? opts.rulesDir
873
+ : join(opts.repoRoot ?? process.cwd(), '.claude', 'rules');
874
+
875
+ /** @type {Array<{ file: string, meta: Record<string, unknown>, expiresAt: string|null }>} */
876
+ const rules = [];
877
+ let names;
878
+ try {
879
+ names = readdirSync(dir);
880
+ } catch (err) {
881
+ // ENOENT is an answer ("no generated rules"); anything else is not knowing.
882
+ return { ok: err?.code === 'ENOENT', rules };
883
+ }
884
+
885
+ for (const file of names) {
886
+ if (!file.endsWith('.md')) continue;
887
+ let meta;
888
+ try {
889
+ ({ meta } = parseGlobsFrontmatter(readFileSync(join(dir, file), 'utf8')));
890
+ } catch {
891
+ continue; // unreadable or unparseable — skip, never throw
892
+ }
893
+ if (!isMachineGeneratedRule(meta)) continue;
894
+ const raw = meta?.['expires-at'];
895
+ rules.push({ file, meta, expiresAt: typeof raw === 'string' ? raw : null });
896
+ }
897
+
898
+ return { ok: true, rules };
899
+ }
900
+
761
901
  /**
762
902
  * Single-pass scan of `.claude/rules/*.md` producing the TWO corpus measures
763
903
  * this guard's third axis needs, which #1297 proved are NOT the same set: