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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -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 +7 -5
- 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/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- 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 +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- 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 +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- 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/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -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-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- 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 +219 -11
- 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-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -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 +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- 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/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- 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/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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
|
|
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 =
|
|
@@ -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
|
-
|
|
374
|
-
|
|
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).
|