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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +494 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +757 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import {
|
|
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 === '' ||
|
|
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
|
-
*
|
|
218
|
+
* `deriveRepo` LIVES IN `./namespace.mjs` and is re-exported here (issue #734b).
|
|
143
219
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|