session-orchestrator 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. 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
- let rel;
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 18 call sites do not repeat them.
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
- if (legacyFm && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === slug) {
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
- if (!fm || !fm['_generator']) {
742
- // Hand-written: skip
743
- process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
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
- if (fm['_generator'] !== GENERATOR_MARKER) {
748
- // Different generator — treat as hand-written to be safe
749
- process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
750
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
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
- if (!disambigFm || !disambigFm['_generator']) {
764
- process.stderr.write(`SKIP hand-written (disambig): ${targetPath}\n`);
765
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
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
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: disambigSlug });
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, { action: 'skipped-collision-resolved', path: targetPath, id: slug });
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 ? { meta: { healed_legacy_flat: true } } : {}),
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
- if (legacyFm && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id) {
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
- if (!fm || !fm['_generator']) {
982
- // Hand-written: skip
983
- process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
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
- if (fm['_generator'] !== GENERATOR_MARKER) {
988
- process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
989
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
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
- // Same generator: check id and updated
993
- if (fm['id'] === session_id) {
994
- const entryUpdated = toDate(entry.completed_at);
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 ? { meta: { healed_legacy_flat: true } } : {}),
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 (the operator's own config
32
- * path, shown transiently on their terminal) but never the real slugs or rejected
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}: ${err.message}; ignoring the map (owner-leaky repos fall back to 'redacted-repo')\n`,
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 d = { ...DEFAULT_DEPS, ...deps };
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.