session-orchestrator 3.20.0 → 3.22.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 (202) 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/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -262,10 +262,23 @@ export function renderRule(learning, metadata) {
262
262
  // purely by `host-class:` (loads only on a matching host) — the intended
263
263
  // behavior, and still never always-on (host-class IS an activation axis).
264
264
  if (globs.length > 0) {
265
- fm.push('globs:');
266
- for (const glob of globs) {
267
- // Always double-quote keeps `*`/`[`/`{` safe; loader strips quotes.
268
- fm.push(` - "${glob}"`);
265
+ // TWO keys, same list, on purpose (#1108). `globs:` is what THIS repo's
266
+ // `rule-loader.mjs` and Cursor read; `paths:` is the ONLY key Claude Code's
267
+ // own loader honours, and a rule without it is loaded UNCONDITIONALLY into
268
+ // every agent (https://code.claude.com/docs/en/memory § Path-specific
269
+ // rules). Emitting `globs:` alone therefore produced a rule that LOOKS
270
+ // scoped, IS scoped for us, and is always-on for the harness — measured
271
+ // 2026-08-22: 16 such rules put 72_195 bytes (~18_300 tokens) into every
272
+ // single dispatch. `check-rules.mjs` now hard-FAILs that shape, so a
273
+ // globs-only rule emitted here would be rejected by the gate the moment it
274
+ // lands. Keep the two lists IDENTICAL: divergence is its own FAIL class,
275
+ // because it would scope Claude Code and Cursor to different rule sets.
276
+ for (const key of ['globs', 'paths']) {
277
+ fm.push(`${key}:`);
278
+ for (const glob of globs) {
279
+ // Always double-quote — keeps `*`/`[`/`{` safe; loader strips quotes.
280
+ fm.push(` - "${glob}"`);
281
+ }
269
282
  }
270
283
  }
271
284
  if (hostClass !== undefined) {
@@ -123,12 +123,37 @@ function writeTextAtomic(destPath, content) {
123
123
  * `globs=null, meta={}, parseError=true` → always-on again, and with empty
124
124
  * meta it passes every gate by design.
125
125
  *
126
- * Scope note: the checks below fire only for a document that DECLARES
127
- * `auto-generated: true` (plus the parse check, which applies to every
128
- * document). The module's contract stays general a caller may still write a
129
- * document with no frontmatter but every document the reconcile pipeline
130
- * actually produces carries `auto-generated: true` on its first frontmatter
131
- * line, so the machine-authored path is fully covered.
126
+ * Scope (#1018 L2) three tiers, deliberately not one:
127
+ *
128
+ * 1. PARSE every document. Unparseable means unsafe, not unknown.
129
+ * 2. `alwaysApply: true` — every document, regardless of any marker. A rule
130
+ * file that DECLARES itself always-on is precisely the outcome this gate
131
+ * exists to prevent, and declaring it must not be the way around the gate.
132
+ * Before #1018 this check sat behind the `auto-generated: true` branch, so
133
+ * a document with `alwaysApply: true` and no `auto-generated` key was
134
+ * written to disk unexamined — measured, not inferred: a `writeApprovedRules`
135
+ * probe returned `written: 1` for exactly that input.
136
+ * 3. The never-always-on invariant set (activation axis, non-empty globs,
137
+ * `learning-key`, `expires-at`) — every document carrying ANY machine-
138
+ * provenance marker (`auto-generated: true`, `learning-key`, or
139
+ * `expires-at`). Keying on the marker SET rather than on `auto-generated`
140
+ * alone means a document that loses its `auto-generated` line to truncation
141
+ * but keeps its provenance keys is still held to the invariant.
142
+ *
143
+ * Why tier 3 is marker-scoped and not universal — measured against the live
144
+ * corpus, not assumed: applying the invariant set to EVERY document would
145
+ * refuse 16 of the 29 files currently in `.claude/rules/`. Those 16 are the
146
+ * hand-authored always-on rules (`development.md`, `security.md`,
147
+ * `parallel-sessions.md`, …), for which always-on is the intended, correct
148
+ * shape — they carry no frontmatter at all. A universal gate would therefore
149
+ * refuse the legitimate majority of the corpus to catch a machine-path defect.
150
+ * Marker-scoped refuses 0 of 29. The module's general contract survives: a
151
+ * caller may still write a document with no frontmatter.
152
+ *
153
+ * CEILING (BV-004): the marker set is a fixed three-key list, not a schema
154
+ * lookup. Revisit if the renderer gains a fourth provenance key, or if a second
155
+ * non-reconcile caller of `writeApprovedRules` appears — today there is exactly
156
+ * one production caller and it passes renderer output.
132
157
  *
133
158
  * Mirrors `scripts/lib/validate/check-rules.mjs`, which enforces the same
134
159
  * invariants as a CI gate. Two enforcement points, one invariant: CI catches
@@ -147,37 +172,51 @@ function frontmatterRefusalReason(content) {
147
172
  }
148
173
 
149
174
  const { globs, meta } = parsed;
150
-
151
- // Not a machine-authored auto-generated rule → the never-always-on invariant
152
- // does not bind (see the scope note above).
153
- if (meta['auto-generated'] !== true) return null;
175
+ const hasKey = (key) => Object.prototype.hasOwnProperty.call(meta, key);
154
176
 
155
177
  const problems = [];
156
178
 
157
- const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
158
- const hasGlobs = Array.isArray(globs) && globs.length > 0;
159
- const hasHostClass = Object.prototype.hasOwnProperty.call(meta, 'host-class');
160
-
161
- if (hasEmptyGlobs) {
162
- // NOT the "no axis" case and NOT always-on — the opposite: rule-loader.mjs
163
- // excludes on `globs.length === 0` unconditionally, AFTER gating, so the
164
- // rule never loads in ANY context even alongside a host-class: key.
165
- problems.push('empty globs array (globs: []) — the rule would match nothing and never load in ANY context');
166
- } else if (!hasGlobs && !hasHostClass) {
167
- problems.push('no activation axis (globs absent AND host-class absent) — the rule would load always-on');
168
- }
169
- if (!Object.prototype.hasOwnProperty.call(meta, 'learning-key')) {
170
- problems.push('missing required frontmatter key: learning-key');
171
- }
172
- if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) {
173
- problems.push('missing required frontmatter key: expires-at');
174
- }
179
+ // ── Tier 2: binds on EVERY document (see the scope note above) ────────────
180
+ // Not gated behind any marker: a document that declares itself always-on is
181
+ // the exact outcome this gate prevents, so the declaration cannot be the
182
+ // escape hatch. The renderer only ever emits `alwaysApply: false`.
175
183
  if (meta.alwaysApply === true) {
176
- problems.push('alwaysApply: true on an auto-generated rule — the renderer only ever emits false');
184
+ problems.push(
185
+ 'alwaysApply: true — a rule written through this writer must never declare itself always-on (the renderer only ever emits false)',
186
+ );
187
+ }
188
+
189
+ // ── Tier 3: binds on any machine-provenance-bearing document ──────────────
190
+ // Marker-scoped rather than universal so the hand-authored always-on corpus
191
+ // (16 of 29 live rule files) keeps writing; marker-scoped rather than
192
+ // `auto-generated`-only so a document that loses that line to truncation but
193
+ // keeps its provenance keys is still held to the invariant.
194
+ const isProvenanceBearing =
195
+ meta['auto-generated'] === true || hasKey('learning-key') || hasKey('expires-at');
196
+
197
+ if (isProvenanceBearing) {
198
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
199
+ const hasGlobs = Array.isArray(globs) && globs.length > 0;
200
+ const hasHostClass = hasKey('host-class');
201
+
202
+ if (hasEmptyGlobs) {
203
+ // NOT the "no axis" case and NOT always-on — the opposite: rule-loader.mjs
204
+ // excludes on `globs.length === 0` unconditionally, AFTER gating, so the
205
+ // rule never loads in ANY context even alongside a host-class: key.
206
+ problems.push('empty globs array (globs: []) — the rule would match nothing and never load in ANY context');
207
+ } else if (!hasGlobs && !hasHostClass) {
208
+ problems.push('no activation axis (globs absent AND host-class absent) — the rule would load always-on');
209
+ }
210
+ if (!hasKey('learning-key')) {
211
+ problems.push('missing required frontmatter key: learning-key');
212
+ }
213
+ if (!hasKey('expires-at')) {
214
+ problems.push('missing required frontmatter key: expires-at');
215
+ }
177
216
  }
178
217
 
179
218
  if (problems.length === 0) return null;
180
- return `auto-generated rule fails the never-always-on invariant: ${problems.join('; ')}`;
219
+ return `rule fails the never-always-on invariant: ${problems.join('; ')}`;
181
220
  }
182
221
 
183
222
  // ---------------------------------------------------------------------------
@@ -0,0 +1,89 @@
1
+ /**
2
+ * redact-spans.mjs — order-independent, overlap-safe span redaction primitive.
3
+ *
4
+ * Extracted verbatim from `scripts/lib/validate/check-owner-leakage.mjs` (issue
5
+ * #974) so that every redaction sink in the repo shares ONE implementation of the
6
+ * overlap-safe span merge rather than each re-deriving it (and each re-deriving
7
+ * the prefix/suffix bug described below). Pure and synchronous — no I/O, no
8
+ * module state, no dependencies — because hot-path consumers import it.
9
+ *
10
+ * Behaviour is unchanged from the original: this module is a move, not a rewrite.
11
+ *
12
+ * DELIBERATE SECOND COPY — `check-owner-leakage.mjs` still carries its OWN inline
13
+ * `redactSpans()` and must keep it. That scanner is a documented standalone
14
+ * single-file vendoring target (`.claude/rules/security.md` § "Owner-Privacy
15
+ * Pre-Commit Hook"): consumer repos copy that ONE file, so a static import of this
16
+ * module throws ERR_MODULE_NOT_FOUND in every vendored copy and blocks all commits
17
+ * (proven by the three cases in `tests/husky/pre-commit-owner-leakage.test.mjs`).
18
+ * Unlike CP11's confidential-names helpers, a redaction sink cannot degrade to
19
+ * inert — an absent redaction prints confidential names into a PUBLIC CI log.
20
+ * So: THIS module serves in-tree consumers, the inline copy serves the vendored
21
+ * path, and `tests/lib/redact-spans.test.mjs` pins the two byte-for-byte against
22
+ * each other. Edit one → that drift guard goes red until both agree again.
23
+ */
24
+
25
+ /**
26
+ * Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
27
+ *
28
+ * This is the single redaction sink for the confidential-names privacy invariant.
29
+ * In `check-owner-leakage.mjs` it is applied at the print choke-point over EVERY
30
+ * violation's lineContent — not only CP11 hits — because that scanner runs in a
31
+ * PUBLIC GitHub-Actions mirror: a confidential customer/repo name that co-occurs
32
+ * with a CP1–CP10 hit on the same line (e.g. a name beside an RFC1918 IP that
33
+ * fails CP8) would otherwise be echoed verbatim to the public CI log (Fix 1).
34
+ *
35
+ * ORDER-INDEPENDENCE (Fix 2): a naïve chain of `.replace()` calls is order-dependent
36
+ * — when one configured name is a PREFIX of another (`['acme','acme-corp-secret']`),
37
+ * redacting the shorter first destroys the longer's match and leaks a suffix residue
38
+ * (`[REDACTED]-corp-secret`). Instead we compute ALL match spans against the ORIGINAL
39
+ * (unmutated) string across every pattern, merge overlapping/adjacent intervals, and
40
+ * splice `[REDACTED]` per merged interval. No pattern ever sees a string another
41
+ * pattern already rewrote, so prefix/suffix overlap cannot leak regardless of list
42
+ * order.
43
+ *
44
+ * @param {string} line — the raw (already-trimmed) violation lineContent.
45
+ * @param {RegExp[]} patterns — confidential-name regexes (word-boundary, case-insensitive).
46
+ * @returns {string} the line with every configured name span replaced by [REDACTED].
47
+ */
48
+ export function redactSpans(line, patterns) {
49
+ if (!Array.isArray(patterns) || patterns.length === 0) return line;
50
+
51
+ // 1. Collect [start, end) spans of every match of every pattern against the
52
+ // ORIGINAL line (never a partially-mutated one). Global clone so exec() walks
53
+ // all matches; zero-width guard prevents an infinite loop on a degenerate regex.
54
+ const spans = [];
55
+ for (const re of patterns) {
56
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
57
+ let m;
58
+ while ((m = g.exec(line)) !== null) {
59
+ if (m[0].length === 0) {
60
+ g.lastIndex += 1;
61
+ continue;
62
+ }
63
+ spans.push([m.index, m.index + m[0].length]);
64
+ }
65
+ }
66
+ if (spans.length === 0) return line;
67
+
68
+ // 2. Merge overlapping / adjacent intervals (sorted by start).
69
+ spans.sort((a, b) => a[0] - b[0]);
70
+ const merged = [];
71
+ for (const [s, e] of spans) {
72
+ const last = merged[merged.length - 1];
73
+ if (last && s <= last[1]) {
74
+ last[1] = Math.max(last[1], e);
75
+ } else {
76
+ merged.push([s, e]);
77
+ }
78
+ }
79
+
80
+ // 3. Splice [REDACTED] per merged interval, left-to-right over the ORIGINAL line.
81
+ let out = '';
82
+ let cursor = 0;
83
+ for (const [s, e] of merged) {
84
+ out += line.slice(cursor, s) + '[REDACTED]';
85
+ cursor = e;
86
+ }
87
+ out += line.slice(cursor);
88
+ return out;
89
+ }