session-orchestrator 3.19.0 → 3.21.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 (158) 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/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -0,0 +1,61 @@
1
+ // frontmatter-block.mjs — Extract the leading `---`-fenced YAML block from a Markdown file.
2
+ //
3
+ // SHARED: THE EXTRACTION. NOT SHARED: THE RULES.
4
+ //
5
+ // This module owns exactly one thing — finding the frontmatter block's byte range and
6
+ // handing back its text. It deliberately owns NOTHING about what a valid frontmatter
7
+ // contains. Do not grow it into a validator, and do not "unify" the callers' rules
8
+ // against it: the three frontmatter checkers in this directory disagree on purpose.
9
+ //
10
+ // * check-agents.mjs BANS `description: >` — the agent loader cannot read a folded
11
+ // block scalar, so the form is a live defect for agents/*.md.
12
+ // * check-skills.mjs ALLOWS `description: >` — for SKILL.md the sign is REVERSED: the
13
+ // folded scalar is the only form that makes the `: `
14
+ // collision inside an unquoted description structurally
15
+ // impossible. Measured 2026-08-15: 23 of 46 SKILL.md files
16
+ // use it, and porting the agent ban here would red 35 of 46.
17
+ // * check-commands.mjs has its own, narrower field contract again (`argument-hint`).
18
+ //
19
+ // Those divergences are the product requirement, not drift. What WAS drift is this
20
+ // function: it stood verbatim in check-skills.mjs and check-commands.mjs, so the next
21
+ // change to the block format (a BOM, a new delimiter tolerance) would have been made in
22
+ // one copy and one gate would have started accepting what the other rejects — with no
23
+ // test able to see it, because each gate tested its own copy.
24
+ //
25
+ // BEHAVIOUR IS PINNED, NOT ASPIRATIONAL. tests/lib/validate/frontmatter-block.test.mjs
26
+ // documents what the body does today, including the two sharp edges below. Both are
27
+ // intentional records of the status quo, NOT endorsements — changing either is a
28
+ // behaviour change under two gates at once and needs its own task, not a drive-by edit.
29
+ //
30
+ // 1. CRLF input is handled: the split is `/\r?\n/`, so a CRLF file's first line
31
+ // compares equal to '---' and the returned yamlText is LF-normalised.
32
+ // 2. A line that is exactly `---` INSIDE the block (e.g. an unindented `---` inside a
33
+ // multi-line string) terminates the block early. Only a column-0, whitespace-free
34
+ // `---` does this; an indented ` ---` is ordinary content.
35
+
36
+ /**
37
+ * Extract the YAML frontmatter block delimited by the leading `---` fence.
38
+ *
39
+ * The opening fence must be the very first line of the file. The closing fence is the
40
+ * first subsequent line equal to `---`. Line endings may be LF or CRLF; the returned
41
+ * text is always LF-joined.
42
+ *
43
+ * @param {string} content - full file text
44
+ * @returns {{ ok: true, yamlText: string } | { ok: false, diagnostic: string }}
45
+ * On success, `yamlText` is the block's inner text with no fences (empty string for an
46
+ * empty block). On failure, `diagnostic` is a caller-printable reason and there is no
47
+ * `yamlText` — callers must not fall through to a parse.
48
+ */
49
+ export function extractInitialFrontmatter(content) {
50
+ const lines = content.split(/\r?\n/);
51
+ if (lines[0] !== '---') {
52
+ return { ok: false, diagnostic: 'missing YAML frontmatter opening delimiter' };
53
+ }
54
+
55
+ const closingDelimiter = lines.indexOf('---', 1);
56
+ if (closingDelimiter === -1) {
57
+ return { ok: false, diagnostic: 'missing YAML frontmatter closing delimiter' };
58
+ }
59
+
60
+ return { ok: true, yamlText: lines.slice(1, closingDelimiter).join('\n') };
61
+ }
@@ -25,7 +25,36 @@ const WRITE_TOOLS = new Set(['Edit', 'Write']);
25
25
 
26
26
  // Tools that are acceptable in the read-only tier (Bash is fine — fine-grained
27
27
  // Bash control lives in hooks/pre-bash-destructive-guard.mjs, NOT here).
28
- const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Bash', 'Skill']);
28
+ //
29
+ // SendMessage / ListAgents are pure agent-to-coordinator communication surfaces
30
+ // with no filesystem write path, so they do not lift an agent out of read-only
31
+ // (#1049, PRD § 2 A5). Without them here, every read-only agent that opts into
32
+ // SendMessage silently infers `repo-write` and trips validateTierConsistency.
33
+ const READ_ONLY_TOOLS = new Set([
34
+ 'Read',
35
+ 'Grep',
36
+ 'Glob',
37
+ 'Bash',
38
+ 'Skill',
39
+ 'SendMessage',
40
+ 'ListAgents',
41
+ ]);
42
+
43
+ /**
44
+ * Normalise one raw frontmatter tool entry to its lookup key.
45
+ *
46
+ * Single source of truth for the "Skill(...)" → "Skill" collapse: both
47
+ * inferTierFromTools and validateTierConsistency's detail text must agree on
48
+ * what a tool entry IS, or the error message names offenders the inference
49
+ * never objected to.
50
+ *
51
+ * @param {unknown} t - raw entry from the frontmatter tools array
52
+ * @returns {string} lookup key
53
+ */
54
+ function normaliseTool(t) {
55
+ if (typeof t !== 'string') return String(t);
56
+ return t.startsWith('Skill(') ? 'Skill' : t.trim();
57
+ }
29
58
 
30
59
  /**
31
60
  * Infer the sandbox tier from a parsed tools array.
@@ -47,10 +76,7 @@ export function inferTierFromTools(toolsArray) {
47
76
  }
48
77
 
49
78
  // Normalise "Skill(...)" → "Skill"
50
- const normalised = toolsArray.map((t) => {
51
- if (typeof t !== 'string') return String(t);
52
- return t.startsWith('Skill(') ? 'Skill' : t.trim();
53
- });
79
+ const normalised = toolsArray.map(normaliseTool);
54
80
 
55
81
  // Any write tool → repo-write
56
82
  for (const t of normalised) {
@@ -86,10 +112,22 @@ export function validateTierConsistency({ declared, inferred, tools }) {
86
112
 
87
113
  // 2. Read-only agent must not have write tools.
88
114
  if (declared === 'read-only' && inferred !== 'read-only') {
89
- const writeToolsPresent = Array.isArray(tools)
90
- ? tools.filter((t) => WRITE_TOOLS.has(t))
115
+ // Name EVERY tool responsible for the verdict, not only the write tools.
116
+ // inferTierFromTools falls through to `repo-write` for any UNRECOGNISED
117
+ // tool too, and filtering the detail text on WRITE_TOOLS alone reported
118
+ // "tools suggest repo-write" with no culprit named in exactly that case
119
+ // (#1049). Normalised via the same helper the inference uses, so a
120
+ // "Skill(...)" entry is never listed as an offender.
121
+ const offenders = Array.isArray(tools)
122
+ ? [
123
+ ...new Set(
124
+ tools
125
+ .map(normaliseTool)
126
+ .filter((t) => WRITE_TOOLS.has(t) || !READ_ONLY_TOOLS.has(t)),
127
+ ),
128
+ ]
91
129
  : [];
92
- const detail = writeToolsPresent.length > 0 ? ` (tools include: ${writeToolsPresent.join(', ')})` : '';
130
+ const detail = offenders.length > 0 ? ` (tools include: ${offenders.join(', ')})` : '';
93
131
  return {
94
132
  ok: false,
95
133
  error: `agent declares sandbox-tier "read-only" but tools suggest "${inferred}"${detail}`,
@@ -22,13 +22,31 @@
22
22
  * - Redacts UNMAPPED owner-privacy leaks (CP1/CP6/CP10) to 'redacted-repo' +
23
23
  * stderr WARN — identical to pre-#725 behaviour when no map is configured.
24
24
  * - Falls back to 'unknown-repo' when slug derivation produces an empty string.
25
+ *
26
+ * Dependency direction (issue #734b): this module OWNS {@link deriveRepo}; it does
27
+ * NOT import from `./process.mjs`. Until #734b, `deriveRepo` lived in `process.mjs`
28
+ * while `process.mjs` imported `resolveRepoNamespace` from here — the repo's only
29
+ * import cycle (`namespace.mjs ↔ process.mjs`). The cycle was broken by moving the
30
+ * *identity* half down here (this module is the repo-identity resolver; `process.mjs`
31
+ * is the record-mirroring pipeline that CONSUMES an identity), and `process.mjs`
32
+ * re-exports `deriveRepo` from here so its public surface is unchanged.
33
+ *
34
+ * The direction is load-bearing beyond cycle-breaking: three modules
35
+ * (`vault-repo-backfill.mjs`, `vault-relocation-rules.mjs`, `scripts/vault-mirror.mjs`)
36
+ * import ONLY `resolveRepoNamespace` and previously dragged the entire `process.mjs`
37
+ * graph (secret-masker, render-learnings, render-sessions, session-schema/filters)
38
+ * in behind it. Keep this module leaf-ward: it may import `./utils.mjs`,
39
+ * `./pseudonym-map.mjs`, the leak-guard, host-paths and `../vcs-repo-spec.mjs`
40
+ * (itself a leaf — `node:child_process` only) — never the pipeline.
25
41
  */
26
42
 
27
- import { deriveRepo } from './process.mjs';
43
+ import { basename } from 'node:path';
44
+
28
45
  import { subjectToSlug } from './utils.mjs';
29
46
  import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
30
47
  import { loadPseudonymMap } from './pseudonym-map.mjs';
31
48
  import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
49
+ import { isQueryFailure, resolvePreferredRemote } from '../vcs-repo-spec.mjs';
32
50
 
33
51
  // ── Lazy pseudonym-map path resolution (Epic #725 D5) ────────────────────────
34
52
  // The map path comes from env SO_NAMESPACE_MAP > owner.yaml paths.namespace-map-path
@@ -68,6 +86,133 @@ function currentMapPath() {
68
86
  return _lazyPath;
69
87
  }
70
88
 
89
+ // ── Repo identity (issue #343; moved here from process.mjs for #734b) ────────
90
+
91
+ let _cachedRepo = null;
92
+
93
+ /** scp-like SSH remote: `git@host:org/name.git` (no `://`, an `@` before any `/`). */
94
+ const SCP_LIKE_REMOTE_RE = /^[^@/\s]+@[^:/\s]+:(.+)$/;
95
+
96
+ /** `scheme://[authority]/path` remote: https, ssh, git, file, … */
97
+ const SCHEME_REMOTE_RE = /^([a-z][a-z0-9+.-]*):\/\/[^/]*\/(.+)$/i;
98
+
99
+ /**
100
+ * Split a remote's path portion into meaningful segments: drop a trailing
101
+ * `.git` (with any trailing slashes), then discard empty and `.`/`..` segments.
102
+ *
103
+ * The `.`-dropping is the load-bearing part: `git clone <path>/.` records the
104
+ * origin VERBATIM as `/…/<repo>/.`, so the final segment of a filesystem remote
105
+ * is routinely a bare dot (measured golden record, 2026-08-19).
106
+ *
107
+ * @param {string} path
108
+ * @returns {string[]}
109
+ */
110
+ function remotePathSegments(path) {
111
+ return path
112
+ .replace(/\.git\/*$/i, '')
113
+ .split('/')
114
+ .map((segment) => segment.trim())
115
+ .filter((segment) => segment !== '' && segment !== '.' && segment !== '..');
116
+ }
117
+
118
+ /**
119
+ * Derive the RAW repo identifier from one git remote URL.
120
+ *
121
+ * - Hosted remote (scp-like SSH or a non-`file` scheme URL) → the last two path
122
+ * segments, `org/name` — byte-identical to the pre-#1039 regex for every
123
+ * hosted shape, so no existing vault namespace moves.
124
+ * - Filesystem remote (`git clone <path>`, `file://…`) → the repo DIRECTORY
125
+ * name alone. A local clone has no owner segment, so `org/name` is not
126
+ * derivable and inventing one from the parent directory would namespace vault
127
+ * notes under an arbitrary path component.
128
+ *
129
+ * Returns `''` when nothing usable can be derived (caller falls back).
130
+ *
131
+ * @param {string} url
132
+ * @returns {string}
133
+ */
134
+ function repoIdentifierFromRemoteUrl(url) {
135
+ const value = String(url ?? '').trim();
136
+ if (value === '') return '';
137
+
138
+ const scp = SCP_LIKE_REMOTE_RE.exec(value);
139
+ const asUrl = scp === null ? SCHEME_REMOTE_RE.exec(value) : null;
140
+ const isFileUrl = asUrl !== null && asUrl[1].toLowerCase() === 'file';
141
+
142
+ if (scp !== null || (asUrl !== null && !isFileUrl)) {
143
+ const segments = remotePathSegments(scp !== null ? scp[1] : asUrl[2]);
144
+ if (segments.length === 0) return '';
145
+ return segments.length >= 2
146
+ ? `${segments[segments.length - 2]}/${segments[segments.length - 1]}`
147
+ : segments[segments.length - 1];
148
+ }
149
+
150
+ const segments = remotePathSegments(isFileUrl ? asUrl[2] : value);
151
+ return segments.length === 0 ? '' : segments[segments.length - 1];
152
+ }
153
+
154
+ /**
155
+ * Derive the canonical repo identifier for cross-repo vault aggregation (issue #343).
156
+ *
157
+ * Strategy (#1039): ask the shared remote-resolution core for the repo's
158
+ * PREFERRED remote — `resolvePreferredRemote` without a `vcs`, i.e. the
159
+ * `origin` → `gitlab` → `github` order, plus its sole-remote fallback — then
160
+ * derive `org/name` (hosted) or the repo directory name (filesystem clone) from
161
+ * that remote's URL. The pre-#1039 implementation read the hard-coded literal
162
+ * `git remote get-url origin`, which produced two live defects:
163
+ *
164
+ * 1. A repo whose remotes are named `gitlab`/`github` (no `origin`) silently
165
+ * namespaced its vault notes under the CHECKOUT DIRECTORY name.
166
+ * 2. A `git clone <path>` origin (`/…/<repo>/.` — what the pre-push hook's
167
+ * clone records) parsed to `<repo>/.`, whose slug is empty, so
168
+ * {@link resolveRepoNamespace} returned `'unknown-repo'`. Measured
169
+ * 2026-08-19; it turned a namespace assertion red and blocked a push.
170
+ *
171
+ * Fallback: `path.basename(process.cwd())`, as before — but the two reasons for
172
+ * reaching it are no longer indistinguishable. A QUERY FAILURE (not a git repo,
173
+ * git not on PATH, git errored — {@link isQueryFailure}) emits a stderr WARN,
174
+ * because the identity under which vault notes are written was GUESSED. A real
175
+ * ABSENCE (a repo with no remotes) stays silent: that is a legitimate repo state
176
+ * and the directory name is the best available identity, not a degraded one.
177
+ * An `ok` resolution whose URL yields no usable identifier also falls back
178
+ * silently — the query succeeded and the answer was simply unusable.
179
+ *
180
+ * Cached per-process — repo identity does not change mid-run, and the cache also
181
+ * keeps the WARN to at most one line per process.
182
+ *
183
+ * NOTE — this is the RAW identifier and is NOT leak-guarded. Never write its
184
+ * output to the vault directly; route it through {@link resolveRepoNamespace}
185
+ * (which is what the `vaultName`-less path below does). The WARN above therefore
186
+ * deliberately does NOT print the derived value.
187
+ *
188
+ * Re-exported by `./process.mjs` for backwards compatibility — that was its home
189
+ * until the #734b cycle break, and the module-level cache means there must remain
190
+ * exactly ONE definition.
191
+ *
192
+ * @returns {string} e.g. 'Kanevry/session-orchestrator' or a bare directory name.
193
+ */
194
+ export function deriveRepo() {
195
+ if (_cachedRepo !== null) return _cachedRepo;
196
+
197
+ const resolved = resolvePreferredRemote({});
198
+ if (resolved.ok) {
199
+ const identifier = repoIdentifierFromRemoteUrl(resolved.url);
200
+ if (identifier !== '') {
201
+ _cachedRepo = identifier;
202
+ return _cachedRepo;
203
+ }
204
+ } else if (isQueryFailure(resolved.reason)) {
205
+ process.stderr.write(
206
+ `WARN vault-mirror/namespace: could not query git remotes (${resolved.reason}); ` +
207
+ 'falling back to the checkout directory name — vault notes may be namespaced ' +
208
+ 'under the directory rather than the repo identity\n',
209
+ );
210
+ }
211
+
212
+ _cachedRepo = basename(process.cwd());
213
+ return _cachedRepo;
214
+ }
215
+
71
216
  /**
72
217
  * Look up a stable pseudonym for this repo. Checks the sanitised segment first
73
218
  * (the canonical, stable key) then the raw base (covers a vaultName override
@@ -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 =