session-orchestrator 5.4.0 → 5.5.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/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
9
|
+
import { join, resolve, sep } from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
10
11
|
import { randomUUID } from 'node:crypto';
|
|
11
12
|
import { createSecretValueMasker } from '../secret-masker.mjs';
|
|
12
13
|
import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
|
|
13
14
|
import { isRealSession } from '../session-schema/filters.mjs';
|
|
14
15
|
import { resolveRepoNamespace } from './namespace.mjs';
|
|
15
|
-
import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2 } from './render-learnings.mjs';
|
|
16
|
-
import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3 } from './render-sessions.mjs';
|
|
16
|
+
import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
|
|
17
|
+
import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3, sessionSourceRecord } from './render-sessions.mjs';
|
|
17
18
|
import { emitMirrorEvent } from './telemetry.mjs';
|
|
18
19
|
|
|
19
20
|
const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
@@ -244,6 +245,179 @@ function maskerWouldChange(text) {
|
|
|
244
245
|
return ensureMasker().mask(text) !== text;
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
// ── Provenance guard (#1503) ──────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Was the generator note on disk rendered from a DIFFERENT record than the one
|
|
252
|
+
* about to be written over it? Returns the reason string when it was, `null`
|
|
253
|
+
* when the write may proceed.
|
|
254
|
+
*
|
|
255
|
+
* WHY. Before #1503 the only ownership test before an overwrite was the
|
|
256
|
+
* `_generator` marker plus the note `id` — and the `id` is derived from the
|
|
257
|
+
* learning SUBJECT or the semantic `session_id`, both of which recur across
|
|
258
|
+
* repos. A run whose namespace came out wrong therefore overwrote another
|
|
259
|
+
* project's notes whenever the subject or session id matched, and the content
|
|
260
|
+
* comparison then made it worse: an OLDER foreign record overwrote a NEWER note
|
|
261
|
+
* because its content merely differed.
|
|
262
|
+
*
|
|
263
|
+
* The owner field is `source-record` (learning `id`; session `raw_session_id`,
|
|
264
|
+
* else `started_at` — see the renderers), compared with the writer's
|
|
265
|
+
* `recordKey`. A key that occurs ANYWHERE in the writer's own `--source` ledger
|
|
266
|
+
* (`ownRecordKeys`, superseded duplicates included) is the writer's own: another
|
|
267
|
+
* repo's ledger cannot contain this repo's session uuid or start time, while a
|
|
268
|
+
* backfill stub replaced by its real record, a repair re-append, or a reused
|
|
269
|
+
* session id all keep their earlier key in the same ledger. The CLI fills
|
|
270
|
+
* `ownRecordKeys` for sessions only — for learnings a record mismatch is routed
|
|
271
|
+
* to the existing `<slug>-<uuid8>` disambiguation instead (see processLearning).
|
|
272
|
+
*
|
|
273
|
+
* `source-repo` is DELIBERATELY NOT an owner signal. A note found at the target
|
|
274
|
+
* path already sits in the writer's namespace folder, so a `source-repo` that
|
|
275
|
+
* differs from it means the note was moved or its repo relabelled (a
|
|
276
|
+
* `.vault.yaml` slug that differs from the remote name, a relocation) — never
|
|
277
|
+
* that another repo wrote it. Treating it as foreign froze a repo's own notes
|
|
278
|
+
* and blocked their secret self-heal (#1503 review F1). It would not have caught
|
|
279
|
+
* #1503 either: the overwritten notes carried the writer's own namespace, both
|
|
280
|
+
* having come from the same wrong cwd.
|
|
281
|
+
*
|
|
282
|
+
* ABSENT IS ALLOW, on either side. A note written before the field existed, or
|
|
283
|
+
* a writer whose record key cannot be represented (`recordKey: null`), proves
|
|
284
|
+
* nothing either way — refusing those would freeze every pre-#1503 note.
|
|
285
|
+
*
|
|
286
|
+
* A foreign note that still leaks under the current masker is NOT rewritten
|
|
287
|
+
* from the writer's record — that would be the #1503 overwrite again, merely
|
|
288
|
+
* triggered by a secret. It is masked IN PLACE instead (see
|
|
289
|
+
* {@link maskForeignNoteInPlace}): owner, content and `source-record` stay, only
|
|
290
|
+
* the secret goes, and the writer's record is routed exactly as for any foreign
|
|
291
|
+
* note.
|
|
292
|
+
*
|
|
293
|
+
* NAMED LIMITATION — this is defence in depth, never the fix. It cannot protect
|
|
294
|
+
* a note written before `source-record` existed (no field, nothing to compare).
|
|
295
|
+
* What prevents the incident class is the CLI resolving the repo root from
|
|
296
|
+
* `--source` and refusing a `--source`/`--repo-root` mismatch
|
|
297
|
+
* (`scripts/vault-mirror.mjs`, `source-repo-mismatch`).
|
|
298
|
+
*
|
|
299
|
+
* The returned reason is a constant: it never echoes a value read from disk.
|
|
300
|
+
*
|
|
301
|
+
* @param {Record<string, string>|null} fm - parsed frontmatter of the note on disk.
|
|
302
|
+
* @param {{ recordKey?: string|null, ownRecordKeys?: Set<string>|null }} writer
|
|
303
|
+
* @returns {string|null}
|
|
304
|
+
*/
|
|
305
|
+
export function foreignOwnerReason(fm, { recordKey = null, ownRecordKeys = null } = {}) {
|
|
306
|
+
const present = (v) => typeof v === 'string' && v.length > 0;
|
|
307
|
+
const diskRecord = fm?.['source-record'];
|
|
308
|
+
if (!present(diskRecord) || !present(recordKey) || diskRecord === recordKey) return null;
|
|
309
|
+
if (ownRecordKeys instanceof Set && ownRecordKeys.has(diskRecord)) return null;
|
|
310
|
+
return 'source-record mismatch';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The generator-marker half of note ownership, checked BEFORE the record-level
|
|
315
|
+
* {@link foreignOwnerReason}: `'hand-written'` when the note carries no
|
|
316
|
+
* `_generator`, `'unknown generator'` when another tool wrote it, `null` when
|
|
317
|
+
* it is ours. Every branch that may write an existing note consults this one
|
|
318
|
+
* predicate — the disambiguation branch used to test only the first half, so a
|
|
319
|
+
* `<slug>-<uuid8>.md` written by another generator (no `source-record`, so
|
|
320
|
+
* `foreignOwnerReason` allows it) was overwritten (#1506.3). The returned label
|
|
321
|
+
* is the stderr wording of the SKIP line.
|
|
322
|
+
*
|
|
323
|
+
* @param {Record<string, string>|null} fm - parsed frontmatter of the note on disk.
|
|
324
|
+
* @returns {'hand-written'|'unknown generator'|null}
|
|
325
|
+
*/
|
|
326
|
+
function notOurGenerator(fm) {
|
|
327
|
+
if (!fm || !fm['_generator']) return 'hand-written';
|
|
328
|
+
if (fm['_generator'] !== GENERATOR_MARKER) return 'unknown generator';
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
|
|
334
|
+
* `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
|
|
335
|
+
* action carrying `meta.reason`.
|
|
336
|
+
* @returns {Promise<string>} the action string.
|
|
337
|
+
*/
|
|
338
|
+
function skipForeignOwner(lineNum, ctx, { path, id, reason, healedLeak = false }) {
|
|
339
|
+
process.stderr.write(`SKIP foreign owner (${reason}): ${toVaultRelative(path, ctx.vaultDir)}\n`);
|
|
340
|
+
return emitEntryAction(lineNum, ctx, {
|
|
341
|
+
action: 'skipped-foreign-owner',
|
|
342
|
+
path,
|
|
343
|
+
id,
|
|
344
|
+
meta: { reason, ...(healedLeak ? { healed_leak: true } : {}) },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Lazily resolved js-yaml (null when not installed) — same pattern as vault-yaml.mjs. */
|
|
349
|
+
let _yaml;
|
|
350
|
+
function getYaml() {
|
|
351
|
+
if (_yaml === undefined) {
|
|
352
|
+
try {
|
|
353
|
+
_yaml = createRequire(import.meta.url)('js-yaml');
|
|
354
|
+
} catch {
|
|
355
|
+
_yaml = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return _yaml;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Mask the secrets of a note that belongs to ANOTHER record, in place (#1503).
|
|
363
|
+
*
|
|
364
|
+
* The note keeps its owner, its content and its `source-record`; only the
|
|
365
|
+
* values the current masker knows become `[REDACTED]`. Re-rendering it from the
|
|
366
|
+
* writer's record instead would overwrite another project's note — the #1503
|
|
367
|
+
* incident, triggered by a secret (cycle-3 review: an older foreign record
|
|
368
|
+
* replaced the newer note and froze it).
|
|
369
|
+
*
|
|
370
|
+
* Masking finished Markdown is what `maskEntrySecrets` deliberately avoids
|
|
371
|
+
* (its reason 3): a frontmatter value that STARTS with the secret becomes a
|
|
372
|
+
* bare `[REDACTED]…`, which YAML reads as a flow sequence, and a secret inside
|
|
373
|
+
* a flow list breaks the list. So the masked frontmatter must still parse as
|
|
374
|
+
* YAML, or nothing is written: one stderr WARN, and the leak stays — loudly
|
|
375
|
+
* rather than replaced by a note the vault-sync gate rejects. Without js-yaml
|
|
376
|
+
* the check cannot run and the same refusal applies.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} path - absolute note path.
|
|
379
|
+
* @param {string} content - the note as read from disk.
|
|
380
|
+
* @param {{ vaultDir: string, dryRun: boolean }} ctx
|
|
381
|
+
* @returns {boolean} true when masked (or would be, in dry-run).
|
|
382
|
+
*/
|
|
383
|
+
function maskForeignNoteInPlace(path, content, { vaultDir, dryRun }) {
|
|
384
|
+
const masked = ensureMasker().mask(content);
|
|
385
|
+
if (masked === content) return false;
|
|
386
|
+
const yaml = getYaml();
|
|
387
|
+
let parses = false;
|
|
388
|
+
if (yaml !== null && masked.startsWith('---')) {
|
|
389
|
+
const end = masked.indexOf('\n---', 3);
|
|
390
|
+
try {
|
|
391
|
+
const fm = end === -1 ? null : yaml.load(masked.slice(3, end));
|
|
392
|
+
parses = fm !== null && typeof fm === 'object' && !Array.isArray(fm);
|
|
393
|
+
} catch {
|
|
394
|
+
parses = false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!parses) {
|
|
398
|
+
process.stderr.write(
|
|
399
|
+
`WARN vault-mirror: a leaked secret in a note owned by another record could not be masked in place (the masked frontmatter would not parse); left unchanged: ${toVaultRelative(path, vaultDir)}\n`,
|
|
400
|
+
);
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (!dryRun) writeFileSync(path, masked, 'utf8');
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The boolean heal flags for an action's meta, or nothing when neither is set.
|
|
409
|
+
* @param {boolean} healedLegacyFlat - our own legacy flat note re-rendered masked.
|
|
410
|
+
* @param {boolean} healedForeignLeak - another record's note masked in place.
|
|
411
|
+
* @returns {{ meta?: Record<string, boolean> }}
|
|
412
|
+
*/
|
|
413
|
+
function healMeta(healedLegacyFlat, healedForeignLeak) {
|
|
414
|
+
const meta = {
|
|
415
|
+
...(healedLegacyFlat ? { healed_legacy_flat: true } : {}),
|
|
416
|
+
...(healedForeignLeak ? { healed_leak: true } : {}),
|
|
417
|
+
};
|
|
418
|
+
return Object.keys(meta).length > 0 ? { meta } : {};
|
|
419
|
+
}
|
|
420
|
+
|
|
247
421
|
// ── repo derivation ───────────────────────────────────────────────────────────
|
|
248
422
|
|
|
249
423
|
/**
|
|
@@ -263,6 +437,33 @@ export { deriveRepo } from './namespace.mjs';
|
|
|
263
437
|
|
|
264
438
|
// ── Action output ─────────────────────────────────────────────────────────────
|
|
265
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Render a vault file path relative to the vault root — the ONE form either
|
|
442
|
+
* output channel (stdout action records, stderr `SKIP …` lines) may carry. The
|
|
443
|
+
* vault lives under the operator's home dir, and both channels reach CI logs
|
|
444
|
+
* and session transcripts (session-end pipes stderr into its own output), so an
|
|
445
|
+
* absolute path there is an owner-privacy leak (#1487 item 9,
|
|
446
|
+
* `.claude/rules/security.md` § Owner-Privacy).
|
|
447
|
+
*
|
|
448
|
+
* "Inside" is decided per path segment, not per string prefix: a sibling dir
|
|
449
|
+
* such as `/vault2` is outside `/vault`, and a vault at the filesystem root
|
|
450
|
+
* keeps every character of the relative path. A path outside the vault is
|
|
451
|
+
* returned unchanged; the vault root itself becomes `''`. Every caller in this
|
|
452
|
+
* module builds its path from `resolve(vaultDir)`, so the outside branch is
|
|
453
|
+
* unreached today.
|
|
454
|
+
*
|
|
455
|
+
* @param {string|null|undefined} path — absolute path under `vaultDir`, or null
|
|
456
|
+
* @param {string} vaultDir — vault root
|
|
457
|
+
* @returns {string|null} vault-relative path, or null for a null/absent path
|
|
458
|
+
*/
|
|
459
|
+
function toVaultRelative(path, vaultDir) {
|
|
460
|
+
if (path === null || path === undefined) return null;
|
|
461
|
+
const root = resolve(vaultDir);
|
|
462
|
+
if (path === root) return '';
|
|
463
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
464
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
465
|
+
}
|
|
466
|
+
|
|
266
467
|
/**
|
|
267
468
|
* Emit a JSON action line to stdout.
|
|
268
469
|
*
|
|
@@ -315,15 +516,7 @@ export async function emitAction({
|
|
|
315
516
|
skipClass,
|
|
316
517
|
reason,
|
|
317
518
|
}) {
|
|
318
|
-
|
|
319
|
-
if (path === null || path === undefined) {
|
|
320
|
-
rel = null;
|
|
321
|
-
} else {
|
|
322
|
-
const resolvedVaultDir = resolve(vaultDir);
|
|
323
|
-
rel = path.startsWith(resolvedVaultDir)
|
|
324
|
-
? path.slice(resolvedVaultDir.length + 1)
|
|
325
|
-
: path;
|
|
326
|
-
}
|
|
519
|
+
const rel = toVaultRelative(path, vaultDir);
|
|
327
520
|
const payload = { action, path: rel, kind, id };
|
|
328
521
|
if (meta && typeof meta === 'object') {
|
|
329
522
|
Object.assign(payload, meta);
|
|
@@ -370,7 +563,7 @@ export async function emitAction({
|
|
|
370
563
|
/**
|
|
371
564
|
* Per-entry stdout + telemetry wrapper used by {@link processLearning} and
|
|
372
565
|
* {@link processSession}. Threads the line number, kind, vaultDir and dry-run
|
|
373
|
-
* flag out of the processor `ctx` so the
|
|
566
|
+
* flag out of the processor `ctx` so the 19 call sites do not repeat them.
|
|
374
567
|
*
|
|
375
568
|
* @param {number} lineNum — 1-based JSONL line number of the entry.
|
|
376
569
|
* @param {object} ctx — processor context (`vaultDir`, `dryRun`, `kind`).
|
|
@@ -678,6 +871,12 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
678
871
|
// renderer reads opts.repoNs (see render-learnings.mjs); when absent (older
|
|
679
872
|
// callers), the source-repo line is omitted — backward-compatible.
|
|
680
873
|
generatorOpts.repoNs = repoNs;
|
|
874
|
+
// #1503: the record key this write would stamp — compared against an existing
|
|
875
|
+
// note's `source-record` before any overwrite. No `ownRecordKeys` for
|
|
876
|
+
// learnings: a mismatch is routed to the `<slug>-<uuid8>` disambiguation below,
|
|
877
|
+
// so two records sharing a subject keep two stable notes instead of
|
|
878
|
+
// overwriting each other on every run.
|
|
879
|
+
const writer = { recordKey: learningSourceRecord(entry) };
|
|
681
880
|
const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
682
881
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
683
882
|
|
|
@@ -692,19 +891,30 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
692
891
|
// Set when a still-leaking legacy flat note was healed in place below, so the
|
|
693
892
|
// action emitted for THIS entry can say so (see the write sites at the end).
|
|
694
893
|
let healedLegacyFlat = false;
|
|
894
|
+
// #1503: set when a leaking note owned by ANOTHER record was masked in place.
|
|
895
|
+
let healedForeignLeak = false;
|
|
695
896
|
if (!existsSync(targetPath) && existsSync(legacyFlatPath)) {
|
|
696
897
|
const legacyContent = readFileSync(legacyFlatPath, 'utf8');
|
|
697
898
|
const legacyFm = parseFrontmatter(legacyContent);
|
|
899
|
+
// #1028 residue 2 (W4 review HIGH-2): the leak probe must run on EVERY
|
|
900
|
+
// path that leaves the branch below, not only the date-not-advanced one —
|
|
901
|
+
// every one of them falls through to the NAMESPACED path, after which
|
|
902
|
+
// `existsSync(targetPath)` is true forever and this legacy file is never
|
|
903
|
+
// read again. A raw secret left in it would become unreachable to the
|
|
904
|
+
// self-heal while staying fully published in the tracked vault.
|
|
905
|
+
const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
|
|
698
906
|
// Only skip if the flat note is ours (has our generator marker and matching id).
|
|
699
|
-
|
|
907
|
+
// #1503: a flat note rendered from another record is NOT ours either — it is
|
|
908
|
+
// neither a duplicate to skip on nor a file to rewrite with THIS record's
|
|
909
|
+
// content; this entry proceeds to its own namespaced path (a different
|
|
910
|
+
// file). A leak in it is masked in place, keeping its owner and content.
|
|
911
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === slug;
|
|
912
|
+
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
913
|
+
if (legacyForeign && legacyStillLeaks) {
|
|
914
|
+
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
915
|
+
}
|
|
916
|
+
if (legacyMarked && !legacyForeign) {
|
|
700
917
|
const entryUpdated = toDate(dateSource);
|
|
701
|
-
// #1028 residue 2 (W4 review HIGH-2): the leak probe must run on EVERY
|
|
702
|
-
// path that leaves this branch, not only the date-not-advanced one — every
|
|
703
|
-
// one of them falls through to the NAMESPACED path, after which
|
|
704
|
-
// `existsSync(targetPath)` is true forever and this legacy file is never
|
|
705
|
-
// read again. A raw secret left in it would become unreachable to the
|
|
706
|
-
// self-heal while staying fully published in the tracked vault.
|
|
707
|
-
const legacyStillLeaks = maskerWouldChange(legacyContent);
|
|
708
918
|
if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
709
919
|
// Date has not advanced — but content may have changed (confidence, insight, etc.).
|
|
710
920
|
// Render the candidate and compare canonical fields before deciding to skip.
|
|
@@ -738,20 +948,36 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
738
948
|
const existingContent = readFileSync(targetPath, 'utf8');
|
|
739
949
|
const fm = parseFrontmatter(existingContent);
|
|
740
950
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
951
|
+
// Hand-written, or another generator's note — treated as hand-written to be safe.
|
|
952
|
+
const notOurs = notOurGenerator(fm);
|
|
953
|
+
if (notOurs !== null) {
|
|
954
|
+
process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(targetPath, vaultDir)}\n`);
|
|
744
955
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
|
|
745
956
|
}
|
|
746
957
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
958
|
+
// #1503: same id is not same record — the id is the SUBJECT slug, which
|
|
959
|
+
// recurs across repos AND within one repo (an expired learning re-learned
|
|
960
|
+
// under a new id). A note rendered from a different record is therefore a
|
|
961
|
+
// slug collision: the new record goes to the disambiguated path, the note on
|
|
962
|
+
// disk is never overwritten, and `--force` does not change that. A leak in
|
|
963
|
+
// that note is masked in place first — never by rewriting it from THIS
|
|
964
|
+
// record, which would be the #1503 overwrite triggered by a secret.
|
|
965
|
+
//
|
|
966
|
+
// BV-004 ceiling: (1) the main `<slug>.md` keeps the FIRST record that
|
|
967
|
+
// claimed it — after a re-learn it goes stale while the current record lives
|
|
968
|
+
// at `<slug>-<uuid8>.md`; (2) disambiguation is ONE level deep — a
|
|
969
|
+
// `-<uuid8>` file owned by yet another record is skipped-foreign-owner.
|
|
970
|
+
// Fine while re-learns are rare (fleet census at review time: 39 slugs in 12
|
|
971
|
+
// repos). Revisit-Trigger: a stale main note is read as current, or a
|
|
972
|
+
// skipped-foreign-owner is reported on a `-<uuid8>` path.
|
|
973
|
+
const recordMismatch = foreignOwnerReason(fm, writer);
|
|
974
|
+
const routedByRecord = recordMismatch !== null;
|
|
975
|
+
if (routedByRecord && maskerWouldChange(existingContent)) {
|
|
976
|
+
healedForeignLeak = maskForeignNoteInPlace(targetPath, existingContent, ctx);
|
|
751
977
|
}
|
|
752
978
|
|
|
753
|
-
if (fm['id'] !== slug) {
|
|
754
|
-
// Different id → collision: disambiguate
|
|
979
|
+
if (fm['id'] !== slug || routedByRecord) {
|
|
980
|
+
// Different id (or different record) → collision: disambiguate
|
|
755
981
|
const disambigSlug = `${slug}-${uuidPrefix8(entryId)}`;
|
|
756
982
|
targetPath = join(targetDir, `${disambigSlug}.md`);
|
|
757
983
|
slug = disambigSlug;
|
|
@@ -760,9 +986,26 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
760
986
|
// Still exists with disambig — check if it's ours
|
|
761
987
|
const disambigContent = readFileSync(targetPath, 'utf8');
|
|
762
988
|
const disambigFm = parseFrontmatter(disambigContent);
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
989
|
+
const disambigNotOurs = notOurGenerator(disambigFm);
|
|
990
|
+
if (disambigNotOurs !== null) {
|
|
991
|
+
process.stderr.write(`SKIP ${disambigNotOurs} (disambig): ${toVaultRelative(targetPath, vaultDir)}\n`);
|
|
992
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
993
|
+
action: 'skipped-handwritten',
|
|
994
|
+
path: targetPath,
|
|
995
|
+
id: entryId,
|
|
996
|
+
...healMeta(false, healedForeignLeak),
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
const disambigForeign = foreignOwnerReason(disambigFm, writer);
|
|
1000
|
+
if (disambigForeign !== null) {
|
|
1001
|
+
const healedDisambig =
|
|
1002
|
+
maskerWouldChange(disambigContent) && maskForeignNoteInPlace(targetPath, disambigContent, ctx);
|
|
1003
|
+
return skipForeignOwner(_lineNum, ctx, {
|
|
1004
|
+
path: targetPath,
|
|
1005
|
+
id: disambigSlug,
|
|
1006
|
+
reason: disambigForeign,
|
|
1007
|
+
healedLeak: healedDisambig || healedForeignLeak,
|
|
1008
|
+
});
|
|
766
1009
|
}
|
|
767
1010
|
// Check updated advancement; if date has not advanced, also diff content.
|
|
768
1011
|
// #1028 residue 1: this guard previously lacked the `!force &&` that the
|
|
@@ -776,7 +1019,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
776
1019
|
// masker before trusting the five-field match — see maskerWouldChange.
|
|
777
1020
|
const stillLeaks = maskerWouldChange(disambigContent);
|
|
778
1021
|
if (!stillLeaks) {
|
|
779
|
-
|
|
1022
|
+
// #1503: the main note's in-place heal must stay visible even when
|
|
1023
|
+
// this record's own disambiguated note is unchanged.
|
|
1024
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1025
|
+
action: 'skipped-noop',
|
|
1026
|
+
path: targetPath,
|
|
1027
|
+
id: disambigSlug,
|
|
1028
|
+
...healMeta(false, healedForeignLeak),
|
|
1029
|
+
});
|
|
780
1030
|
}
|
|
781
1031
|
}
|
|
782
1032
|
// Content differs, or the on-disk note still leaks under the current
|
|
@@ -786,7 +1036,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
786
1036
|
|
|
787
1037
|
const content = generator(entry, slug, generatorOpts);
|
|
788
1038
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
789
|
-
return emitEntryAction(_lineNum, ctx, {
|
|
1039
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1040
|
+
action: 'skipped-collision-resolved',
|
|
1041
|
+
path: targetPath,
|
|
1042
|
+
id: slug,
|
|
1043
|
+
...(routedByRecord
|
|
1044
|
+
? { meta: { reason: recordMismatch, ...(healedForeignLeak ? { healed_leak: true } : {}) } }
|
|
1045
|
+
: {}),
|
|
1046
|
+
});
|
|
790
1047
|
}
|
|
791
1048
|
|
|
792
1049
|
// Same id: check if updated would advance (unless --force overrides).
|
|
@@ -823,7 +1080,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
823
1080
|
// No PATH in the meta: the stdout payload's `path` is vault-relative on
|
|
824
1081
|
// purpose (emitAction relativises it), a raw absolute path here would put
|
|
825
1082
|
// the operator's home dir on stdout and into the ledger record.
|
|
826
|
-
...(healedLegacyFlat
|
|
1083
|
+
...healMeta(healedLegacyFlat, healedForeignLeak),
|
|
827
1084
|
});
|
|
828
1085
|
}
|
|
829
1086
|
|
|
@@ -936,6 +1193,20 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
936
1193
|
const targetDir = join(resolve(vaultDir), '50-sessions', repoNs);
|
|
937
1194
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
938
1195
|
|
|
1196
|
+
// #1503: the record key this write would stamp, plus every key the writer's
|
|
1197
|
+
// OWN `--source` ledger carries (`ctx.sourceRecordKeys`, filled by the CLI
|
|
1198
|
+
// from all session lines including superseded duplicates). A note whose key
|
|
1199
|
+
// is in that set is this repo's note even when the key changed under the same
|
|
1200
|
+
// session_id — a backfill stub replaced by its real record, a repair
|
|
1201
|
+
// re-append — so canonicalizeSessions' "newest wins" still reaches the vault.
|
|
1202
|
+
const writer = { recordKey: sessionSourceRecord(entry), ownRecordKeys: ctx?.sourceRecordKeys ?? null };
|
|
1203
|
+
// The id THIS record renders into the note. It can differ from the filename
|
|
1204
|
+
// id `session_id`: the renderers slugify the raw session_id with whitespace
|
|
1205
|
+
// → hyphens (`slugifyIdSafe`), while the filename strips whitespace
|
|
1206
|
+
// (`subjectToSlug`), caps at 240 chars, or falls back to a uuid slug. A note
|
|
1207
|
+
// carrying either id is this record's own note.
|
|
1208
|
+
const renderedId = parseFrontmatter(renderedBody)?.id;
|
|
1209
|
+
|
|
939
1210
|
// Canonical filename pattern (issue #343): `<session_id>.md` where session_id
|
|
940
1211
|
// follows `<branch>-<YYYY-MM-DD>-<HHmm>-<slug>` per the session-id schema.
|
|
941
1212
|
// session_id has been validated/sanitised above (isValidSlug → subjectToSlug
|
|
@@ -948,17 +1219,26 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
948
1219
|
// (pre-namespace migration), skip creating a duplicate.
|
|
949
1220
|
const legacyFlatPath = join(resolve(vaultDir), '50-sessions', `${session_id}.md`);
|
|
950
1221
|
let healedLegacyFlat = false;
|
|
1222
|
+
// #1503: set when a leaking note owned by ANOTHER record was masked in place.
|
|
1223
|
+
let healedForeignLeak = false;
|
|
951
1224
|
if (!existsSync(targetPath) && existsSync(legacyFlatPath)) {
|
|
952
1225
|
const legacyContent = readFileSync(legacyFlatPath, 'utf8');
|
|
953
1226
|
const legacyFm = parseFrontmatter(legacyContent);
|
|
954
|
-
|
|
1227
|
+
// #1028 residue 1, session channel (W4 review HIGH-1): the skip decision
|
|
1228
|
+
// below is DATE-ONLY — it never looks at the note's content, so a raw
|
|
1229
|
+
// secret written into the narrative before the env carried the needle
|
|
1230
|
+
// would stay published forever. Same `maskerWouldChange` probe the
|
|
1231
|
+
// learning channel carries; see that function above.
|
|
1232
|
+
const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
|
|
1233
|
+
// #1503: a flat note from a foreign record is not ours — see processLearning's
|
|
1234
|
+
// dual-probe. A leak in it is masked in place, keeping its owner and content.
|
|
1235
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id;
|
|
1236
|
+
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
1237
|
+
if (legacyForeign && legacyStillLeaks) {
|
|
1238
|
+
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
1239
|
+
}
|
|
1240
|
+
if (legacyMarked && !legacyForeign) {
|
|
955
1241
|
const entryUpdated = toDate(entry.completed_at);
|
|
956
|
-
// #1028 residue 1, session channel (W4 review HIGH-1): the skip decision
|
|
957
|
-
// here is DATE-ONLY — it never looks at the note's content, so a raw
|
|
958
|
-
// secret written into the narrative before the env carried the needle
|
|
959
|
-
// would stay published forever. Same `maskerWouldChange` probe the
|
|
960
|
-
// learning channel carries; see that function above.
|
|
961
|
-
const legacyStillLeaks = maskerWouldChange(legacyContent);
|
|
962
1242
|
if (!force && !legacyStillLeaks && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
963
1243
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
964
1244
|
}
|
|
@@ -978,30 +1258,38 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
978
1258
|
const existingContent = readFileSync(targetPath, 'utf8');
|
|
979
1259
|
const fm = parseFrontmatter(existingContent);
|
|
980
1260
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
process.stderr.write(`SKIP
|
|
1261
|
+
const notOurs = notOurGenerator(fm);
|
|
1262
|
+
if (notOurs !== null) {
|
|
1263
|
+
process.stderr.write(`SKIP ${notOurs}: ${toVaultRelative(targetPath, vaultDir)}\n`);
|
|
984
1264
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
|
|
985
1265
|
}
|
|
986
1266
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1267
|
+
// #1028 residue 1, session channel (W4 review HIGH-1): probe the on-disk
|
|
1268
|
+
// content against the CURRENT masker — the session note's narrative body is
|
|
1269
|
+
// the larger free-text leak surface of the two channels.
|
|
1270
|
+
const existingLeaks = maskerWouldChange(existingContent);
|
|
1271
|
+
|
|
1272
|
+
// #1503: session ids are semantic (`main-<date>-session-<n>`) and recur
|
|
1273
|
+
// across repos, so a matching id proves nothing about ownership — the
|
|
1274
|
+
// record key decides. And a note naming a DIFFERENT session (latent bug:
|
|
1275
|
+
// this used to fall through to the create path below, overwriting another
|
|
1276
|
+
// session's note and reporting it `created`) is foreign too; sessions have
|
|
1277
|
+
// no disambiguation scheme. Checked before the date comparison; `--force`
|
|
1278
|
+
// does not bypass it. A leak in a foreign note is masked in place, never
|
|
1279
|
+
// rewritten from this record.
|
|
1280
|
+
const sameSession = fm['id'] === session_id || fm['id'] === renderedId;
|
|
1281
|
+
const foreign = foreignOwnerReason(fm, writer) ?? (sameSession ? null : 'id mismatch');
|
|
1282
|
+
if (foreign !== null) {
|
|
1283
|
+
const healed = existingLeaks && maskForeignNoteInPlace(targetPath, existingContent, ctx);
|
|
1284
|
+
return skipForeignOwner(_lineNum, ctx, { path: targetPath, id: session_id, reason: foreign, healedLeak: healed });
|
|
990
1285
|
}
|
|
991
1286
|
|
|
992
|
-
|
|
993
|
-
if (fm['
|
|
994
|
-
|
|
995
|
-
// #1028 residue 1, session channel (W4 review HIGH-1): probe the on-disk
|
|
996
|
-
// content against the CURRENT masker before trusting the date-only skip —
|
|
997
|
-
// the session note's narrative body is the larger free-text leak surface
|
|
998
|
-
// of the two channels. See maskerWouldChange above.
|
|
999
|
-
if (!force && !maskerWouldChange(existingContent) && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1000
|
-
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1001
|
-
}
|
|
1002
|
-
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|
|
1003
|
-
return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: session_id });
|
|
1287
|
+
const entryUpdated = toDate(entry.completed_at);
|
|
1288
|
+
if (!force && !existingLeaks && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1289
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1004
1290
|
}
|
|
1291
|
+
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|
|
1292
|
+
return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: session_id });
|
|
1005
1293
|
}
|
|
1006
1294
|
|
|
1007
1295
|
// File does not exist — create. Reuse the rendered body computed during the
|
|
@@ -1012,6 +1300,6 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1012
1300
|
path: targetPath,
|
|
1013
1301
|
id: session_id,
|
|
1014
1302
|
// Boolean only — never a path; see the same note in processLearning.
|
|
1015
|
-
...(healedLegacyFlat
|
|
1303
|
+
...healMeta(healedLegacyFlat, healedForeignLeak),
|
|
1016
1304
|
});
|
|
1017
1305
|
}
|
|
@@ -28,12 +28,14 @@
|
|
|
28
28
|
* once per record — the file is read+parsed at most once per path).
|
|
29
29
|
*
|
|
30
30
|
* Privacy: this module never writes the map anywhere; it only reads the operator's
|
|
31
|
-
* host-local file. WARN messages carry the map PATH
|
|
32
|
-
*
|
|
33
|
-
* pseudonym values
|
|
31
|
+
* host-local file. WARN messages carry the map PATH with the home directory
|
|
32
|
+
* collapsed to `~` (`redactHomeDir` — they are echoed at `/close`), and never the
|
|
33
|
+
* real slugs or rejected pseudonym values: a JSON parse error contributes only
|
|
34
|
+
* its position, because V8's message quotes the map content around the error.
|
|
34
35
|
*/
|
|
35
36
|
|
|
36
37
|
import { readFileSync, existsSync } from 'node:fs';
|
|
38
|
+
import { redactHomeDir } from '../common.mjs';
|
|
37
39
|
import { isValidSlug } from './utils.mjs';
|
|
38
40
|
import { isOwnerLeakySegment } from '../validate/check-owner-leakage.mjs';
|
|
39
41
|
|
|
@@ -74,8 +76,11 @@ function parseMap(raw, mapPath, d) {
|
|
|
74
76
|
try {
|
|
75
77
|
parsed = JSON.parse(raw);
|
|
76
78
|
} catch (err) {
|
|
79
|
+
// Only the position: V8's message quotes the map around the error
|
|
80
|
+
// (`Unexpected token 'x', ..."te-repo": x}"`), i.e. a real slug.
|
|
81
|
+
const where = /at position \d+(?: \(line \d+ column \d+\))?/.exec(String(err?.message))?.[0];
|
|
77
82
|
d.warn(
|
|
78
|
-
`WARN vault-mirror/pseudonym-map: malformed JSON in namespace map ${mapPath}
|
|
83
|
+
`WARN vault-mirror/pseudonym-map: malformed JSON in namespace map ${mapPath}${where ? ` ${where}` : ''}; ignoring the map (owner-leaky repos fall back to 'redacted-repo')\n`,
|
|
79
84
|
);
|
|
80
85
|
return null;
|
|
81
86
|
}
|
|
@@ -131,7 +136,11 @@ function parseMap(raw, mapPath, d) {
|
|
|
131
136
|
* @returns {Map<string,string>|null} the validated map, or null when unconfigured/unusable.
|
|
132
137
|
*/
|
|
133
138
|
export function loadPseudonymMap({ mapPath, deps = {} } = {}) {
|
|
134
|
-
const
|
|
139
|
+
const merged = { ...DEFAULT_DEPS, ...deps };
|
|
140
|
+
// Every WARN passes through here, so none prints the home directory — neither
|
|
141
|
+
// in `mapPath` nor in an fs error message that embeds it. The redaction is
|
|
142
|
+
// display-only: `mapPath` itself still drives the cache and the I/O.
|
|
143
|
+
const d = { ...merged, warn: (msg) => merged.warn(redactHomeDir(msg)) };
|
|
135
144
|
|
|
136
145
|
// Unconfigured → no map, no noise. This is the normal case for public repos and
|
|
137
146
|
// for any host that has not opted into pseudonym mapping.
|