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
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
9
- import { join, resolve, basename } from 'node:path';
9
+ import { join, resolve } from 'node:path';
10
10
  import { randomUUID } from 'node:crypto';
11
- import { execFileSync } from 'node:child_process';
11
+ import { createSecretValueMasker } from '../secret-masker.mjs';
12
12
  import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
13
13
  import { isRealSession } from '../session-schema/filters.mjs';
14
14
  import { resolveRepoNamespace } from './namespace.mjs';
@@ -99,6 +99,80 @@ function extractLearningCanonicalFields(noteContent) {
99
99
  return { status, expires, confidence, insight, source_session };
100
100
  }
101
101
 
102
+ /**
103
+ * The marker every redaction sink in this repo splices in (`redact-spans.mjs`).
104
+ * Declared here as a literal rather than imported: `redactSpans` does not export
105
+ * it, and this module needs it as a SEARCH token, not as a replacement.
106
+ */
107
+ const REDACTION_MARKER = '[REDACTED]';
108
+
109
+ /**
110
+ * Does the on-disk `existingVal` match `renderedVal` once every `[REDACTED]`
111
+ * span in it is treated as a wildcard? (#1025)
112
+ *
113
+ * WHY THIS EXISTS. Masking is env-derived and the env is not part of the record,
114
+ * so the two sides of the idempotency comparison can be masked DIFFERENTLY: a
115
+ * note written while `FOO_TOKEN` was set carries `[REDACTED]`, and a later run
116
+ * with that var absent renders the RAW value. Plain equality then reports
117
+ * "content changed" and the mirror WRITES THE RAW SECRET — the repeated leak
118
+ * measured in #1025 Probe A. Treating an on-disk redaction as "some value stood
119
+ * here" makes that second run a `skipped-noop` again.
120
+ *
121
+ * DIRECTION IS DELIBERATE — the wildcard is only ever read off the ON-DISK side.
122
+ * An on-disk `[REDACTED]` is evidence that a mask ran; an on-disk raw value is
123
+ * evidence of nothing, so the reverse (candidate redacted, disk raw) stays a
124
+ * mismatch. See the COLD-START FREEZE note in `maskEntrySecrets` for the residual
125
+ * that this asymmetry leaves open on purpose.
126
+ *
127
+ * NAMED CEILING: the wildcard is exactly as wide as the marker spans — every
128
+ * literal segment AROUND them must still match byte for byte. A genuine content
129
+ * edit that happens to sit entirely inside a redacted span is therefore read as a
130
+ * no-op. That is a bounded over-approximation on a field whose masked half is by
131
+ * definition unpublishable; the alternative (persisting the needle set to disk)
132
+ * would put a secrets file on disk to protect against secrets, which is worse.
133
+ *
134
+ * THE MARKER IS NOT AUTHENTICATED — and it cannot be. `[REDACTED]` is an ordinary
135
+ * string that this repo's own prose uses freely (ADRs, rule files, learnings), so
136
+ * its presence is evidence a mask MAY have run, never proof one did. Two cheap
137
+ * narrowings bound what that costs:
138
+ * - A value consisting of NOTHING BUT markers (`[REDACTED]`, or two in a row)
139
+ * leaves zero literal anchors, compiling to a pattern that matches every
140
+ * string — a field permanently blind to every future edit. Rejected outright:
141
+ * with no anchor there is no evidence of what stood there, so the safe read is
142
+ * "not a redaction of this candidate".
143
+ * - A marker span stands for at least ONE character (`+?`, not `*?`). A masked
144
+ * needle is >= `MIN_MASKABLE_LENGTH` (8) characters by construction, so this
145
+ * never rejects a real redaction and does reject the empty-span reading.
146
+ *
147
+ * REJECTED ALTERNATIVE — gating the wildcard on `needleCount > 0`. It reads as the
148
+ * obvious authentication ("no needles this run, so an on-disk marker cannot be
149
+ * ours") and it destroys the fix, because the leaking run is EXACTLY the run with
150
+ * zero needles: #1025 Probe A reproduced `written` + raw value on the second run
151
+ * precisely because the env no longer carried the secret. Gating there would
152
+ * disable the wildcard in the only case it exists for. The needle count of the
153
+ * CURRENT run says nothing about the env of the run that wrote the file.
154
+ *
155
+ * SHARED BY TWO VAULT SINKS — do not inline a second copy. `writeNarrative` in
156
+ * `scripts/lib/vault-status/narrative-mirror.mjs` imports this for its own
157
+ * skip-noop decision; both write into the same tracked, pushed vault repo, so the
158
+ * contract in `secret-masker.mjs`'s header must hold identically in both. (That
159
+ * module is the natural long-term home for this predicate — see the note there.)
160
+ *
161
+ * @param {string} existingVal — field value parsed out of the note on disk
162
+ * @param {string} renderedVal — same field from the freshly rendered candidate
163
+ * @returns {boolean}
164
+ */
165
+ export function matchesModuloRedaction(existingVal, renderedVal) {
166
+ if (typeof existingVal !== 'string' || typeof renderedVal !== 'string') return false;
167
+ if (!existingVal.includes(REDACTION_MARKER)) return false;
168
+ const segments = existingVal.split(REDACTION_MARKER);
169
+ // Degenerate-wildcard guard: no literal anchor survives, so the pattern would
170
+ // match anything and freeze the field forever. See the header above.
171
+ if (segments.every((segment) => segment === '')) return false;
172
+ const pattern = segments.map((segment) => RegExp.escape(segment)).join('[\\s\\S]+?');
173
+ return new RegExp(`^${pattern}$`).test(renderedVal);
174
+ }
175
+
102
176
  /**
103
177
  * Return true when the existing vault note content and the freshly-rendered
104
178
  * candidate share identical canonical fields (i.e. no meaningful update needed).
@@ -123,8 +197,12 @@ function learningContentMatches(existingContent, renderedContent) {
123
197
  // For each field: if the existing value is absent (empty string), it cannot
124
198
  // signal a mismatch — it means the old note didn't track that field. Only
125
199
  // non-empty existing values are compared against the rendered candidate.
200
+ // #1025: a field whose only difference from the candidate is a `[REDACTED]`
201
+ // span counts as a match — see matchesModuloRedaction above.
126
202
  const fieldMatches = (existingVal, renderedVal) =>
127
- existingVal === '' || existingVal === renderedVal;
203
+ existingVal === '' ||
204
+ existingVal === renderedVal ||
205
+ matchesModuloRedaction(existingVal, renderedVal);
128
206
  return (
129
207
  fieldMatches(existing.status, rendered.status) &&
130
208
  fieldMatches(existing.expires, rendered.expires) &&
@@ -136,35 +214,20 @@ function learningContentMatches(existingContent, renderedContent) {
136
214
 
137
215
  // ── repo derivation ───────────────────────────────────────────────────────────
138
216
 
139
- let _cachedRepo = null;
140
-
141
217
  /**
142
- * Derive the canonical repo identifier for cross-repo vault aggregation (issue #343).
218
+ * `deriveRepo` LIVES IN `./namespace.mjs` and is re-exported here (issue #734b).
143
219
  *
144
- * Strategy: parse `git remote get-url origin` and extract the org/name pair
145
- * (e.g. `git@github.com:Kanevry/session-orchestrator.git` `Kanevry/session-orchestrator`).
146
- * Falls back to `path.basename(process.cwd())` when not in a git repo or origin
147
- * is unavailable. Cached per-process repo identity does not change mid-run.
220
+ * Until #734b this module defined it while `namespace.mjs` imported it and
221
+ * `namespace.mjs` was in turn imported here for `resolveRepoNamespace`, forming
222
+ * the repo's only import cycle. Moving the definition down to the leaf-ward
223
+ * identity module broke the cycle; this re-export keeps `process.mjs`'s public
224
+ * surface unchanged for the existing consumers that import it from here.
225
+ *
226
+ * Do NOT re-add a second definition: `deriveRepo` caches its result in a
227
+ * module-level variable, so a duplicate would produce two independent caches
228
+ * (and two `git remote get-url origin` spawns).
148
229
  */
149
- export function deriveRepo() {
150
- if (_cachedRepo !== null) return _cachedRepo;
151
- try {
152
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
153
- encoding: 'utf8',
154
- stdio: ['ignore', 'pipe', 'ignore'],
155
- }).trim();
156
- // Match git@host:org/name(.git)? OR https://host/org/name(.git)?
157
- const sshMatch = url.match(/[:/]([^:/]+\/[^/]+?)(?:\.git)?$/);
158
- if (sshMatch && sshMatch[1]) {
159
- _cachedRepo = sshMatch[1];
160
- return _cachedRepo;
161
- }
162
- } catch {
163
- // git unavailable or no origin configured — fall through
164
- }
165
- _cachedRepo = basename(process.cwd());
166
- return _cachedRepo;
167
- }
230
+ export { deriveRepo } from './namespace.mjs';
168
231
 
169
232
  // ── Action output ─────────────────────────────────────────────────────────────
170
233
 
@@ -211,6 +274,173 @@ export function emitAction({ action, path, kind, id, vaultDir, meta }) {
211
274
  process.stdout.write(JSON.stringify(payload) + '\n');
212
275
  }
213
276
 
277
+ // ── Secret masking (#974) — THE choke-point ───────────────────────────────────
278
+
279
+ /**
280
+ * Lazily-built, process-wide masker. `createSecretValueMasker` scans the whole
281
+ * env and compiles one RegExp per needle, so it is built ONCE (on the first
282
+ * record) and reused for every record afterwards — never per entry.
283
+ *
284
+ * Lazy rather than module-load-eager so that importing this module for
285
+ * `deriveRepo`/`emitAction` alone costs nothing, and so the env is read at the
286
+ * moment the mirror actually runs.
287
+ *
288
+ * @type {{ mask: (text: string) => string, needleCount: number } | null}
289
+ */
290
+ let _secretMasker = null;
291
+
292
+ /** Records handed to `maskEntrySecrets` this process. Counts only. */
293
+ let _maskedRecords = 0;
294
+ /** String values this process that masking actually CHANGED. Counts only. */
295
+ let _maskHits = 0;
296
+
297
+ /**
298
+ * Build (once) and return the process-wide masker.
299
+ * @returns {{ mask: (text: string) => string, needleCount: number }}
300
+ */
301
+ function ensureMasker() {
302
+ if (_secretMasker === null) _secretMasker = createSecretValueMasker(process.env);
303
+ return _secretMasker;
304
+ }
305
+
306
+ /**
307
+ * Counts-only view of the masking that happened in this process (#1025).
308
+ *
309
+ * Exists so the CLI can emit `orchestrator.secret_masker.applied` at the END of a
310
+ * channel run without reaching into a module-private singleton. It FORCE-BUILDS
311
+ * the masker rather than reporting 0 for an unbuilt one: at 0 processed records
312
+ * the lazy build never fires, and a `needle_count: 0` from that path would be
313
+ * indistinguishable from "this channel has no masking wired at all" — the exact
314
+ * ambiguity the event was added to remove.
315
+ *
316
+ * NEVER returns a needle, a prefix of one, or any masked text — only cardinals.
317
+ *
318
+ * @returns {{ needleCount: number, records: number, hits: number }}
319
+ */
320
+ export function getMaskerStats() {
321
+ return { needleCount: ensureMasker().needleCount, records: _maskedRecords, hits: _maskHits };
322
+ }
323
+
324
+ /**
325
+ * Mask every env-derived secret VALUE occurring anywhere in a mirror record,
326
+ * BEFORE any of it becomes a filename, a stdout line, or vault Markdown.
327
+ *
328
+ * WHY THIS IS THE CHOKE-POINT — and why it is on the INPUT, not the output.
329
+ * Everything this mirror writes lands in a TRACKED, PUSHED artifact
330
+ * (`auto-commit.mjs` runs `git add` + `commit` in the vault repo), so a leak here
331
+ * is not deletable — it would need a history rewrite in a foreign repo that
332
+ * neither this repo's `.gitleaks.toml` nor `check-owner-leakage.mjs` guards.
333
+ *
334
+ * THIS IS NOT THE ONLY SUCH CHANNEL — an earlier revision of this comment claimed
335
+ * it was, and that was wrong. Measured 2026-08-15 against the vault at
336
+ * `83a868059` (`git -C <vault> ls-files`): 18 tracked `_session-narrative.md`
337
+ * files (written by `scripts/lib/vault-status/narrative-mirror.mjs`) and 1 tracked
338
+ * `01-projects/session-orchestrator/research/hardware-patterns.md` (written by
339
+ * `scripts/export-hw-learnings.mjs`) live in the same pushed repo. All three
340
+ * channels carry agent-authored free text and all three need hardening
341
+ * independently — the value masker was wired into `export-hw-learnings.mjs` in
342
+ * #1025 for exactly this reason. Read "the vault is a tracked sink" as the
343
+ * property that makes masking necessary HERE, never as a census of the sinks.
344
+ *
345
+ * The records carry agent-authored free text (`insight`, `evidence`, `notes`,
346
+ * `text`) that routinely quotes command lines and error output, which is exactly
347
+ * the class shape-regexes cannot catch: the VALUE is in the prose, with no
348
+ * `FOO_TOKEN=` key beside it.
349
+ *
350
+ * Masking the ENTRY rather than the rendered Markdown is deliberate, for four
351
+ * reasons — a post-render mask would be wrong on all four:
352
+ * 1. The FILENAME. `slug` / `session_id` derive from `subject` / `session_id`,
353
+ * and the file path is itself committed. A post-render mask never touches
354
+ * the path, so a secret in a subject would be published as a filename.
355
+ * 2. STDOUT. `emitAction` prints the derived `id` and `path`; masking the input
356
+ * keeps the action stream clean too.
357
+ * 3. YAML VALIDITY. The renderers decide quoting with `yamlQuoteIfNeeded`
358
+ * BEFORE emitting `title:`. Masking first lets that decision see the `[`
359
+ * of the marker and quote the scalar; masking afterwards would inject a bare
360
+ * `title: [REDACTED]` — a YAML flow sequence, which fails the vault-sync
361
+ * frontmatter schema at the session-end hard gate.
362
+ * 4. IDEMPOTENCY. `learningContentMatches` compares the on-disk note against a
363
+ * freshly rendered candidate. Masking the input keeps both sides masked, so
364
+ * an already-mirrored record still resolves to `skipped-noop`; masking only
365
+ * on write would make every affected note re-render (and re-commit) forever.
366
+ *
367
+ * That symmetry holds only while the ENV is stable, and the env is not part
368
+ * of the record — so idempotency here is env-DEPENDENT. Reproduced (#1025):
369
+ * a run WITH the secret in env writes `[REDACTED]`; a second run WITHOUT it
370
+ * renders the raw value, the two differ, and the note is `updated` — i.e.
371
+ * the leak is written a second time, by the very run that was supposed to be
372
+ * a no-op. `learningContentMatches` now treats an on-disk `[REDACTED]` span
373
+ * as a wildcard (see that function) so this direction resolves to
374
+ * `skipped-noop` again.
375
+ *
376
+ * KNOWN RESIDUAL — and NOT the one an earlier revision of this note named.
377
+ * That revision claimed a COLD-START FREEZE over the CANONICAL fields: first
378
+ * run without the env writes the raw value, later runs render `[REDACTED]`,
379
+ * and the note freezes. Measured, that direction HEALS: with no marker on the
380
+ * on-disk side `matchesModuloRedaction` returns false at its first line, the
381
+ * canonical fields differ, and the run writes the masked content. The
382
+ * asymmetry is still deliberate (an on-disk redaction is evidence a mask ran;
383
+ * an on-disk raw value is evidence of nothing) — it simply does not freeze
384
+ * anything the field comparison can see.
385
+ *
386
+ * What DOES freeze is the half the field comparison cannot see.
387
+ * `learningContentMatches` compares exactly five canonical fields — `status`,
388
+ * `expires`, `confidence`, `insight`, `source_session`. A raw secret sitting
389
+ * in any OTHER field (`evidence` is the realistic one; it is agent-authored
390
+ * free text and it is rendered into the note) leaves all five identical
391
+ * between the raw on-disk note and the masked candidate. The comparison
392
+ * reports a match, the run emits `skipped-noop`, and the plaintext stays in
393
+ * the tracked, pushed file permanently — no later run rewrites it, because no
394
+ * later run ever sees a difference.
395
+ *
396
+ * THE ESCAPE HATCH EXISTS AND IS UNDOCUMENTED ELSEWHERE, which is the real
397
+ * defect: `processLearning(entry, n, { ...ctx, force: true })` skips the
398
+ * date/content comparison entirely and re-renders from the (masked) entry, so
399
+ * a single forced re-mirror with the env populated heals every such note. It
400
+ * covers the same-id and legacy-flat paths; the disambiguated-collision
401
+ * branch below does not read `force` and is not healed by it.
402
+ * Revisit-Trigger: widen the canonical field set (or diff the whole rendered
403
+ * body) the first time a mirror run is observed leaving a raw needle in a
404
+ * non-canonical field — a test written TODAY would only pin the leak as
405
+ * expected behaviour.
406
+ *
407
+ * FRONTMATTER AND BODY ARE TREATED IDENTICALLY. A credential is exactly as
408
+ * published in `title:` as it is under `## Insight` — both live in the same
409
+ * committed file — so there is no case for exempting the structured half. The
410
+ * schema risk that exemption would otherwise be arguing for is removed by
411
+ * reason 3 above rather than by leaving a field unmasked.
412
+ *
413
+ * Fail-soft by construction: with zero needles the entry is returned by
414
+ * reference (byte-identical downstream), and `mask` itself passes non-strings
415
+ * through — the masker must never be the reason a mirror run dies.
416
+ *
417
+ * @template T
418
+ * @param {T} entry — a normalized learning/session record (plain JSON shape)
419
+ * @returns {T} the same record with every string value masked
420
+ */
421
+ function maskEntrySecrets(entry) {
422
+ const { mask, needleCount } = ensureMasker();
423
+ // Counted BEFORE the fast path: "records the choke-point saw" must not depend
424
+ // on whether the env happened to carry a needle.
425
+ _maskedRecords++;
426
+ if (needleCount === 0) return entry;
427
+ const walk = (value) => {
428
+ if (typeof value === 'string') {
429
+ const masked = mask(value);
430
+ if (masked !== value) _maskHits++;
431
+ return masked;
432
+ }
433
+ if (Array.isArray(value)) return value.map(walk);
434
+ if (value && typeof value === 'object') {
435
+ const out = {};
436
+ for (const [k, v] of Object.entries(value)) out[k] = walk(v);
437
+ return out;
438
+ }
439
+ return value;
440
+ };
441
+ return walk(entry);
442
+ }
443
+
214
444
  // ── Core processing ───────────────────────────────────────────────────────────
215
445
 
216
446
  export async function processLearning(rawEntry, _lineNum, ctx) {
@@ -225,7 +455,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
225
455
  // #635: map producer alias fields (summary/detail, description/rationale,
226
456
  // title/body, name, narrative, content) onto the canonical v1 shape BEFORE
227
457
  // schema detection and slug/id derivation. Canonical entries pass through.
228
- const entry = normalizeLearningEntry(rawEntry);
458
+ // #974: the ONE masking site for learnings — before slug/filename derivation,
459
+ // before the render, before any write. See maskEntrySecrets above.
460
+ const entry = maskEntrySecrets(normalizeLearningEntry(rawEntry));
229
461
  const schema = detectLearningSchema(entry);
230
462
  const entryId = entry.id;
231
463
 
@@ -438,7 +670,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
438
670
  // #635: map producer alias fields (ended_at, mode, total_waves/waves_completed
439
671
  // without a `waves` field) onto the canonical shapes BEFORE schema detection.
440
672
  // Canonical v1/v2/v3 entries pass through untouched.
441
- const entry = normalizeSessionEntry(rawEntry);
673
+ // #974: the ONE masking site for sessions — same contract as processLearning.
674
+ const entry = maskEntrySecrets(normalizeSessionEntry(rawEntry));
442
675
  const { session_id: rawSessionId } = entry;
443
676
  const schema = detectSessionSchema(entry);
444
677
  const generator =
@@ -69,6 +69,50 @@ function renderDuration(durationSeconds, durationMinutes) {
69
69
  return minutes === undefined || minutes === null ? MISSING_VALUE : `${minutes}m`;
70
70
  }
71
71
 
72
+ /**
73
+ * Coerce ONE wave-object count field to a renderable number (#1074).
74
+ *
75
+ * ── WHY A TYPE GUARD AND NOT A BARE `??` CHAIN ─────────────────────────────
76
+ *
77
+ * The wave-object count fields are POLYMORPHIC across producer generations.
78
+ * Census over the live ledger (253 records / 599 wave objects, 2026-08-19):
79
+ *
80
+ * agent_count number 359
81
+ * agents number 210, **ARRAY 14** ← array-of-agent-descriptors
82
+ * agents_dispatched number 20
83
+ * dispatched number 5
84
+ * files_changed number 366, **ARRAY 1** ← array-of-file-paths
85
+ * files number 5
86
+ *
87
+ * A naive `w.agent_count ?? w.agents` therefore interpolates an array of
88
+ * objects into a Markdown table cell — `[object Object],[object Object],…` for
89
+ * the 4 waves of `main-2026-04-27-2231`. That is STRICTLY WORSE than the `?` it
90
+ * replaces: `?` reads as "unknown", `[object Object]` reads as content. The
91
+ * array-of-paths half of the same defect is already in production —
92
+ * `main-2026-05-26-housekeeping-1` renders its whole file list into the Files
93
+ * cell today, via the plain `w.files_changed` read that predates this helper.
94
+ *
95
+ * An array IS a measurement (its length is the count), so it is converted, not
96
+ * dropped. Any OTHER shape returns `undefined` so the caller's `??` chain keeps
97
+ * walking to the next alias and ultimately to `MISSING_CELL` — an unrecognised
98
+ * shape must degrade to "unknown", never to a coerced string. `NaN` is out of
99
+ * reach here by construction: JSON cannot encode it, and every caller feeds
100
+ * this JSONL-parsed values.
101
+ *
102
+ * ABSENT IS NOT ZERO applies unchanged (see the banner above `MISSING_CELL`):
103
+ * this returns `0` for a measured `0` and `0` for an empty array, and only
104
+ * `undefined` for a field the producer never wrote. Callers MUST chain with
105
+ * `??`, never `||` — `agents_dispatched: 0` occurs 20× in the live ledger.
106
+ *
107
+ * @param {unknown} value — one raw wave-object field.
108
+ * @returns {number|undefined} the count, or `undefined` to continue the chain.
109
+ */
110
+ function waveCount(value) {
111
+ if (typeof value === 'number') return value;
112
+ if (Array.isArray(value)) return value.length;
113
+ return undefined;
114
+ }
115
+
72
116
  /**
73
117
  * ── RENDERABLE ⊋ SCHEMA-VALID (#964) ───────────────────────────────────────
74
118
  *
@@ -259,6 +303,29 @@ function fmLine(key, value) {
259
303
  * reachability is therefore zero; see the follow-up note in the #964 report
260
304
  * before treating it as dead code — deletion is a separate decision, and the
261
305
  * `normalizeSessionEntry` alias path can still synthesize a scalar `waves`.
306
+ *
307
+ * #1074 — THE v2 BRANCH WAS DEAD AT BIRTH, AND THE NUMBER IS PINNED. Re-measured
308
+ * 2026-08-19 over 253 records: **v1 253, v2 0, v3 0**, both raw and
309
+ * post-`normalizeSessionEntry`. This is not a coincidence of the current data —
310
+ * the v2 clause is UNSATISFIABLE by construction: it requires
311
+ * `total_agents === undefined`, while `total_agents` is in `REQUIRED_FIELDS`
312
+ * (scripts/lib/session-schema/constants.mjs) and enforced by
313
+ * `validateSession`, so every record the writer accepts carries it (measured:
314
+ * 253/253 define it). Commit 9cb6d9c added the branch 4h23m BEFORE the first
315
+ * record using the `agents` alias was written, and no historical tracked ledger
316
+ * state would have routed one either.
317
+ *
318
+ * The consequence is the reason `generateSessionNote`'s wave table no longer
319
+ * consults this function for its alias handling at all: version detection is
320
+ * the WRONG AXIS for per-wave producer-key drift, because the alias varies per
321
+ * wave object and no session-level field — `schema_version` included — predicts
322
+ * it. See the census in the `waveRows` comment there. This function still
323
+ * routes the three GENERATORS and is unchanged; do not "repair" the wave table
324
+ * by re-anchoring it here.
325
+ *
326
+ * The generators are intentionally NOT deleted (operator decision, deferred
327
+ * once already in #964). Pinning the number here is the point: the next reader
328
+ * inherits the measurement instead of re-running it.
262
329
  */
263
330
  export function detectSessionSchema(entry) {
264
331
  if (!entry) return 'v1';
@@ -368,11 +435,55 @@ export function generateSessionNote(entry, options = {}) {
368
435
  // validated, so all three are optional and were rendering as the literal
369
436
  // string `undefined`. Guarded with the same `?? '?'` the v2 generator's wave
370
437
  // rows already used — `??` so a measured `0` still renders `0`.
438
+ //
439
+ // ── #1074: PER-WAVE ALIASES, NOT PER-SESSION VERSION DETECTION ────────────
440
+ //
441
+ // #M1 stopped the cells reading `undefined`, but left them reading `?` for
442
+ // every record whose producer used a different key — and that is most of the
443
+ // ledger. Census over 253 records / 599 wave objects (2026-08-19):
444
+ //
445
+ // Agents `?` 240 cells in 55 records (recoverable: agents 214, agents_dispatched 20, dispatched 5)
446
+ // Files `?` 232 cells in 52 records (recoverable: files 5)
447
+ // Quality `?` 386 cells in 88 records (recoverable: quality_check 168, status 72, result 35, outcome 5)
448
+ //
449
+ // The version-detection route cannot fix this. `detectSessionSchema` keys on
450
+ // SESSION-level fields, but the alias in use varies PER WAVE OBJECT and no
451
+ // session-level field predicts it — not even `schema_version` (under
452
+ // `schema_version: 1` the ledger carries agent_count 75, agents 33, none 30,
453
+ // agents_dispatched 4, both 2, dispatched 1). The axis is empirically
454
+ // refuted, so the repair is a per-wave alias chain instead.
455
+ //
456
+ // v1-canonical names come FIRST in every chain, so a cell that rendered a
457
+ // value before renders the identical value now: this can only ever replace a
458
+ // `?`. Verified over all 599 wave objects — repaired 239 agents / 5 files /
459
+ // 280 quality, with ZERO existing agents or quality values changed. The one
460
+ // deliberate change is the Files cell of `main-2026-05-26-housekeeping-1`,
461
+ // which stops printing a raw file-path list and prints `3` (see `waveCount`).
462
+ //
463
+ // The Quality chain needs no `waveCount`: all five of its aliases are 100%
464
+ // strings across the ledger (quality 213, quality_check 168, status 72,
465
+ // result 35, outcome 5 — zero objects or arrays), so there is no
466
+ // `[object Object]` hazard to guard. Should a producer ever emit a structured
467
+ // quality verdict, the fixture-coverage test in render-sessions.test.mjs is
468
+ // what surfaces it — this comment is the ceiling, that test is the trigger.
469
+ //
470
+ // A wave with no count under ANY alias keeps rendering `?`, and must: the
471
+ // coord-direct wave of `main-2026-05-26-housekeeping-1` dispatched no agents
472
+ // and recorded no number, so its Agents cell is genuinely unknown. A chain
473
+ // that "repaired" that one too would be inventing a measurement.
371
474
  const waveRows = waves
372
- .map(
373
- (w) =>
374
- `| ${w.wave} | ${w.role} | ${w.agent_count ?? MISSING_CELL} | ${w.files_changed ?? MISSING_CELL} | ${w.quality ?? MISSING_CELL} |`,
375
- )
475
+ .map((w) => {
476
+ const agentsCell =
477
+ waveCount(w.agent_count) ??
478
+ waveCount(w.agents) ??
479
+ waveCount(w.agents_dispatched) ??
480
+ waveCount(w.dispatched) ??
481
+ MISSING_CELL;
482
+ const filesCell = waveCount(w.files_changed) ?? waveCount(w.files) ?? MISSING_CELL;
483
+ const qualityCell =
484
+ w.quality ?? w.quality_check ?? w.status ?? w.result ?? w.outcome ?? MISSING_CELL;
485
+ return `| ${w.wave} | ${w.role} | ${agentsCell} | ${filesCell} | ${qualityCell} |`;
486
+ })
376
487
  .join('\n');
377
488
 
378
489
  // Skip-emit guard: avoid `platform: undefined` literal coercion (issue #343).