session-orchestrator 5.9.0 → 5.10.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 (39) 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-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +33 -2
  6. package/README.md +10 -9
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +35 -5
  10. package/docs/owner-config-schema.md +18 -1
  11. package/docs/owner-privacy-patterns.md +85 -0
  12. package/docs/scope-collision-guard.md +12 -2
  13. package/hooks/enforce-commands.mjs +9 -19
  14. package/hooks/enforce-scope.mjs +23 -42
  15. package/hooks/hooks-codex.json +1 -1
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/post-bash-write-verify.mjs +38 -21
  18. package/package.json +2 -2
  19. package/scripts/emit-session.mjs +15 -0
  20. package/scripts/lib/gates/gate-full.mjs +6 -0
  21. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  22. package/scripts/lib/named-vault-resolver.mjs +1 -1
  23. package/scripts/lib/owner-yaml.mjs +33 -3
  24. package/scripts/lib/platform.mjs +70 -1
  25. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  26. package/scripts/lib/quality-gate.mjs +6 -1
  27. package/scripts/lib/redact-spans.mjs +8 -17
  28. package/scripts/lib/scope-gate.mjs +68 -5
  29. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  30. package/scripts/lib/vault-backfill/template.mjs +1 -1
  31. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  32. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  33. package/scripts/migrate-vault-paths.mjs +57 -60
  34. package/scripts/release.mjs +103 -36
  35. package/scripts/vault-consolidate.mjs +19 -4
  36. package/skills/claude-md-drift-check/SKILL.md +1 -1
  37. package/skills/claude-md-drift-check/checker.mjs +14 -3
  38. package/skills/session-end/metrics-collection.md +26 -2
  39. package/skills/wave-executor/references/wave-loop-review.md +26 -2
@@ -173,7 +173,7 @@ export function renderTemplate({ humanName, slug, tier, gitlabPath, owner }, tem
173
173
 
174
174
  // owner field — value comes from GitLab API (externally controlled).
175
175
  // Use yamlScalar() to produce a quoted scalar, preventing newline injection (CWE-1336, #247).
176
- // The template has a bare unquoted owner value (e.g. `owner: bernhard`);
176
+ // The template has a bare unquoted owner value (e.g. `owner: example-owner`);
177
177
  // the regex replaces only the bare value token, preserving any trailing comment.
178
178
  out = out.replace(/^(\s*owner:\s*)\S+(.*)$/m, `$1${yamlScalar(owner)}$2`);
179
179
 
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { toDate, buildTag, slugifyIdSafe } from './utils.mjs';
19
+ import { stableHostname } from '../host-identity.mjs';
19
20
  import { sourceRecordValue, sourceRecordLine } from './render-learnings.mjs';
20
21
  import { sessionNarrativeText } from './retention.mjs';
21
22
 
@@ -275,6 +276,17 @@ export function vaultStatusForSession(entry) {
275
276
  : VAULT_STATUS_FALLBACK;
276
277
  }
277
278
 
279
+ /**
280
+ * Recorded identity only: never infer the writer's machine for historical notes.
281
+ * Hardware class is a fallback, and cannot distinguish equally equipped hosts.
282
+ */
283
+ function sessionHostLine(entry) {
284
+ const recorded = [entry?.host_id, entry?.host, entry?.host_class]
285
+ .find((value) => typeof value === 'string' && value.trim() !== '');
286
+ if (recorded === undefined) return '';
287
+ return `host: ${JSON.stringify(stableHostname(recorded))}\n`;
288
+ }
289
+
278
290
  /**
279
291
  * Frontmatter line emitter — skips emission when value is null/undefined/empty
280
292
  * to avoid template-literal coercion bugs (e.g. `platform: undefined` → "undefined").
@@ -549,7 +561,7 @@ status: ${vaultStatus}
549
561
  created: ${created}
550
562
  updated: ${updated}
551
563
  tags: ${tags}
552
- ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
564
+ ${sessionHostLine(entry)}${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
553
565
  ---
554
566
 
555
567
  # Session ${session_id}
@@ -636,7 +648,7 @@ status: ${vaultStatus}
636
648
  created: ${created}
637
649
  updated: ${updated}
638
650
  tags: ${tags}
639
- ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
651
+ ${sessionHostLine(entry)}${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
640
652
  ---
641
653
 
642
654
  # Session ${session_id}
@@ -763,7 +775,7 @@ status: ${vaultStatus}
763
775
  created: ${created}
764
776
  updated: ${updated}
765
777
  tags: ${tags}
766
- ${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
778
+ ${sessionHostLine(entry)}${fmLine('source-repo', repoNs)}${sourceRecordLine(sessionSourceRecord(entry))}_generator: ${GENERATOR_MARKER}
767
779
  ---
768
780
 
769
781
  # Session ${session_id}
@@ -54,6 +54,7 @@ import path from 'node:path';
54
54
 
55
55
  import { emitEvent, sessionAttribution } from '../events.mjs';
56
56
  import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
57
+ import { stableHostname } from '../host-identity.mjs';
57
58
  import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
58
59
  import { parseFrontmatter } from '../vault-mirror/utils.mjs';
59
60
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
@@ -351,6 +352,10 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
351
352
  const semanticFromRegistry = registryEntry
352
353
  ? (registryEntry.semantic_session_id ?? registryEntry.session_id ?? null)
353
354
  : null;
355
+ // Hardware class is only a fallback; it cannot distinguish identical machines.
356
+ const recordedHost = [lock?.host_id, lock?.host, registryEntry?.host_id, registryEntry?.host, registryEntry?.host_class]
357
+ .find((value) => typeof value === 'string' && value.trim() !== '');
358
+ const host = recordedHost === undefined ? null : stableHostname(recordedHost);
354
359
  const session = semanticFromLock ?? semanticFromRegistry ?? null;
355
360
 
356
361
  // branch ONLY exists on the registry entry — the lock has no branch field.
@@ -435,6 +440,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
435
440
  branch: status === STATUS_FREI ? null : branch,
436
441
  mode: status === STATUS_FREI ? null : mode,
437
442
  heartbeat: status === STATUS_FREI ? null : heartbeat,
443
+ host: status === STATUS_FREI ? null : host,
438
444
  });
439
445
  }
440
446
 
@@ -458,7 +464,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
458
464
  *
459
465
  * @param {Array<{ repo: string, key?: string|null, status: string,
460
466
  * session?: string|null, branch?: string|null, mode?: string|null,
461
- * heartbeat?: string|null }>} rows
467
+ * heartbeat?: string|null, host?: string|null }>} rows
462
468
  * @param {{ now: Date, createdIso?: string, updatedPlaceholder?: string }} opts
463
469
  * @returns {string} full markdown (frontmatter + table)
464
470
  */
@@ -495,13 +501,13 @@ export function renderBoard(rows, opts = {}) {
495
501
  lines.push('');
496
502
 
497
503
  // Board table
498
- lines.push('| Repo | Status | Session | Branch | Mode | Last heartbeat | Key |');
499
- lines.push('|---|---|---|---|---|---|---|');
504
+ lines.push('| Repo | Status | Session | Branch | Mode | Last heartbeat | Key | Host |');
505
+ lines.push('|---|---|---|---|---|---|---|---|');
500
506
  for (const row of sortedRows) {
501
507
  lines.push(
502
508
  `| ${cell(row?.repo)} | ${cell(row?.status)} | ${cell(row?.session)} | ` +
503
509
  `${cell(row?.branch)} | ${cell(row?.mode)} | ${cell(fmtHeartbeat(row?.heartbeat))} | ` +
504
- `${cell(row?.key)} |`,
510
+ `${cell(row?.key)} | ${row?.host ? cell(row.host) : ''} |`,
505
511
  );
506
512
  }
507
513
  lines.push('');
@@ -528,9 +534,10 @@ export function normalizeUpdated(content) {
528
534
  * (status carry-over + row preservation for repos not in the current update).
529
535
  *
530
536
  * Tolerant by design: skips the header + separator rows, ignores any line that
531
- * is not a 6- or 7-column table row, and maps the literal '—' placeholder back
537
+ * is not a 6-, 7- or 8-column table row, and maps the literal '—' placeholder back
532
538
  * to null. Unescapes the `\|` pipe-escaping applied by {@link renderBoard}.
533
539
  *
540
+ * Host is appended as column 8 (#1054); older rows retain a null host.
534
541
  * SIX **or** seven columns (#871): the pre-#871 board rendered 6. A hard
535
542
  * `length !== 7` filter would silently DROP every row on an operator's existing
536
543
  * board on the first run after upgrade — the board would appear to reset. A
@@ -553,7 +560,7 @@ export function parseBoardRows(content) {
553
560
  .split(/(?<!\\)\|/)
554
561
  .slice(1, -1)
555
562
  .map((c) => c.trim());
556
- if (cells.length !== 6 && cells.length !== 7) continue;
563
+ if (cells.length !== 6 && cells.length !== 7 && cells.length !== 8) continue;
557
564
  // Skip the header row and the |---|---| separator row.
558
565
  if (cells[0] === 'Repo' || /^-+$/.test(cells[0])) continue;
559
566
  const unesc = (v) => (v === '—' ? null : v.replace(/\\\|/g, '|'));
@@ -561,12 +568,13 @@ export function parseBoardRows(content) {
561
568
  if (repo === null) continue;
562
569
  rows.push({
563
570
  repo,
564
- key: cells.length === 7 ? unesc(cells[6]) : null,
571
+ key: cells.length >= 7 ? unesc(cells[6]) : null,
565
572
  status: cells[1],
566
573
  session: unesc(cells[2]),
567
574
  branch: unesc(cells[3]),
568
575
  mode: unesc(cells[4]),
569
576
  heartbeat: unesc(cells[5]),
577
+ host: cells.length === 8 ? (unesc(cells[7]) || null) : null,
570
578
  });
571
579
  }
572
580
  return rows;
@@ -52,6 +52,7 @@ import { promises as fs, existsSync } from 'node:fs';
52
52
  import { spawnSync } from 'node:child_process';
53
53
  import path from 'node:path';
54
54
  import os from 'node:os';
55
+ import { load as loadYaml } from 'js-yaml';
55
56
  import {
56
57
  loadVaultMigrationRules,
57
58
  VAULT_MIGRATION_RULES_PATH,
@@ -59,6 +60,7 @@ import {
59
60
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
60
61
  import { isMainModule } from './lib/is-main-module.mjs';
61
62
  import { atomicWriteText, envelopeToError } from './lib/io.mjs';
63
+ import { loadHostPaths, resolveHostPath } from './lib/config/host-paths.mjs';
62
64
 
63
65
  // ---------------------------------------------------------------------------
64
66
  // Constants
@@ -89,28 +91,9 @@ function _setSegmentsForTest(oldSeg, newSeg) {
89
91
  }
90
92
 
91
93
  // ── Missing-segment class (GitLab #600 D3) ────────────────────────────────────
92
- // A second, username-independent drift class: a `vault-dir:` value that points at
93
- // `~/Projects/vault` (or `/Users/<user>/Projects/vault`) — MISSING the canonical
94
- // `/Bernhard/` owner segment — must become `~/Projects/Bernhard/vault`.
95
- //
96
- // Scoping discipline (do NOT blunt-rewrite every `~/Projects/` path):
97
- // - Only lines in a `vault-dir:` context (vault-integration.vault-dir /
98
- // vault-sync.vault-dir) are touched. `cache: ~/Projects/vault-backups` and
99
- // other unrelated `~/Projects/...` paths are left untouched.
100
- // - The match anchors the value to `~` or `/Users/<user>` immediately before
101
- // `/Projects/vault`, and requires a path boundary AFTER `vault` (negative
102
- // lookahead `(?![\w-])`) so `vault-backups` / `vaultfoo` never match.
103
- // - Matching `Projects/vault` directly (not `Projects/Bernhard/vault`) yields
104
- // idempotency for free: a canonical line has `Projects/Bernhard/vault`, so
105
- // the pattern does not match it → no `.../Bernhard/Bernhard/vault`.
106
- //
107
- // The owner segment is the canonical `Bernhard`; the rewrite inserts it between
108
- // `Projects/` and `vault`.
109
- const MISSING_SEGMENT_OWNER = 'Bernhard';
110
-
111
- // Classification label for the missing-/Bernhard/-segment drift class. Referenced
112
- // in findMissingSegmentHits() (hit record) and the emit() action strings — hoisted
113
- // here so the literal lives in exactly one place (architect MED #607 D3).
94
+ // Legacy vault-dir values rooted at Projects/vault use an explicit host canonical
95
+ // target. Detection remains narrow; rewriting never guesses a personal segment.
96
+ // Username-owned lines and historical contexts remain separate migration classes.
114
97
  const MISSING_SEGMENT_CLASS = 'vault-dir-missing-segment';
115
98
 
116
99
  const MISSING_SEGMENT_RE = new RegExp(
@@ -243,6 +226,8 @@ historical contexts (decisions.md, history/, archive*).
243
226
 
244
227
  Both --from and --to are required unless username-rewrites[0] is set in
245
228
  ${VAULT_MIGRATION_RULES_PATH}.
229
+ Legacy vault-dir targets use SO_VAULT_DIR or owner.yaml paths.vault-dir;
230
+ unconfigured targets are skipped without guessing a location.
246
231
 
247
232
  Options:
248
233
  --from SEG Literal source segment (e.g. '/Users/oldname/')
@@ -330,8 +315,7 @@ function findCandidateFiles(roots) {
330
315
  /**
331
316
  * Discover files containing a missing-segment `vault-dir:` drift (GitLab #600
332
317
  * D3) — i.e. a `vault-dir:` line pointing at `~/Projects/vault` (or
333
- * `/Users/<user>/Projects/vault`) that lacks the canonical `/Bernhard/` owner
334
- * segment. These files do NOT contain OLD_SEGMENT, so findCandidateFiles() (which
318
+ * `/Users/<user>/Projects/vault`) that may need an explicitly configured canonical target. These files do NOT contain OLD_SEGMENT, so findCandidateFiles() (which
335
319
  * greps OLD_SEGMENT) cannot find them.
336
320
  *
337
321
  * grep matches the literal `Projects/vault`; per-file classification then applies
@@ -350,7 +334,7 @@ function findMissingSegmentFiles(roots) {
350
334
  '--include=*.md',
351
335
  // Coarse literal pre-filter; precise vault-dir/idempotency check happens
352
336
  // per-line in findMissingSegmentHits(). 'Projects/vault' also matches
353
- // canonical 'Projects/Bernhard/vault' lines, but those are dropped later.
337
+ // longer paths; exact per-line matching prevents unrelated rewrites.
354
338
  'Projects/vault',
355
339
  ...roots,
356
340
  ],
@@ -407,8 +391,8 @@ function classifyHit(filePath, line) {
407
391
  /**
408
392
  * Does this line carry a missing-segment `vault-dir:` drift (GitLab #600 D3)?
409
393
  * Returns true only for a `vault-dir:` value at `~/Projects/vault` or
410
- * `/Users/<user>/Projects/vault` that is NOT already canonical
411
- * (`~/Projects/Bernhard/vault`) — see MISSING_SEGMENT_RE for the precise shape.
394
+ * `/Users/<user>/Projects/vault` matching the legacy shape — see MISSING_SEGMENT_RE.
395
+ * Whether the target differs is decided separately by the configured rewrite.
412
396
  * `RegExp.test` advances lastIndex on a /g regex, so reset it before each probe.
413
397
  */
414
398
  function lineHasMissingSegment(line) {
@@ -444,7 +428,7 @@ function isOwnedByUsernamePath(line) {
444
428
  * deliberately skipped here: a `vault-dir: /Users/oldname/Projects/vault` line is
445
429
  * a username drift (→ /Users/newname/Projects/vault), NOT a missing-segment drift.
446
430
  * Without this guard the two classes collide and the username output would gain a
447
- * spurious /Bernhard/ segment.
431
+ * second, unrelated vault-target rewrite.
448
432
  */
449
433
  function findMissingSegmentHits(filePath, content) {
450
434
  const historical = isHistorical(filePath);
@@ -464,37 +448,40 @@ function findMissingSegmentHits(filePath, content) {
464
448
  }
465
449
 
466
450
  /**
467
- * Rewrite missing-segment `vault-dir:` values in `content`: insert the canonical
468
- * `/Bernhard/` owner segment between `Projects/` and `vault`. Only `vault-dir:`
469
- * lines matching MISSING_SEGMENT_RE are touched — unrelated `~/Projects/...`
470
- * paths (e.g. `cache: ~/Projects/vault-backups`) are preserved verbatim.
471
- *
472
- * `originalContent` (the file BEFORE rewriteContent ran) gates which lines are
473
- * eligible: any line whose original carried OLD_SEGMENT is owned by the username
474
- * path and is skipped, even though the username rewrite may have produced a
475
- * `/Users/newname/Projects/vault` form that would otherwise match the regex.
476
- * This keeps the two classes from colliding when both transforms chain.
477
- *
478
- * `originalContent` is REQUIRED (no default): the collision-gate is meaningless
479
- * without the pre-rewrite text, and a `= content` default silently fails open if
480
- * a future caller forgets it — passing the already-rewritten content as its own
481
- * "original" would let a chained username rewrite leak a spurious /Bernhard/
482
- * segment. Callers MUST pass the original content explicitly (architect MED #607 D3).
451
+ * Rewrite a legacy vault-dir base to an explicit canonical target, preserving
452
+ * suffixes, scalar quotes and comments. Missing/invalid targets leave text intact.
453
+ * originalContent is required so username-owned lines are never rewritten twice.
483
454
  */
484
- function rewriteMissingSegment(content, originalContent) {
455
+ function rewriteMissingSegment(content, originalContent, canonicalVaultPath) {
485
456
  const origLines = originalContent.split('\n');
486
- return content
487
- .split('\n')
488
- .map((line, i) => {
489
- // Skip lines the username path owns (matched on the ORIGINAL line text).
490
- if (isOwnedByUsernamePath(origLines[i])) return line;
491
- MISSING_SEGMENT_RE.lastIndex = 0;
492
- return line.replace(
493
- MISSING_SEGMENT_RE,
494
- (_m, prefix, root) => `${prefix}${root}/Projects/${MISSING_SEGMENT_OWNER}/vault`,
495
- );
496
- })
497
- .join('\n');
457
+ if (typeof canonicalVaultPath !== 'string' || !canonicalVaultPath.trim()) return content;
458
+ const target = canonicalVaultPath.trim().replace(/\/+$/, '') || '/';
459
+ if (!target.startsWith('/') && !target.startsWith('~/')) return content;
460
+ if ([...target].some((c) => c.charCodeAt(0) < 32)) return content;
461
+ return content.split('\n').map((line, i) => {
462
+ if (isOwnedByUsernamePath(origLines[i])) return line;
463
+ return line.replace(/(vault-dir:\s*)(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([^#\r\n]*))/g,
464
+ (match, prefix, double, single, bare) => {
465
+ let value = bare;
466
+ if (double !== undefined || single !== undefined) {
467
+ // Decode the complete scalar once so YAML escapes retain their path meaning.
468
+ try {
469
+ value = loadYaml(double !== undefined ? `"${double}"` : `'${single}'`);
470
+ } catch {
471
+ return match; // Leave malformed quoted values untouched.
472
+ }
473
+ }
474
+ const tailSpace = value.match(/\s*$/)[0];
475
+ const pathValue = value.slice(0, value.length - tailSpace.length);
476
+ const legacy = /^(?:~|\/Users\/[^/\s'"]+)\/Projects\/vault(?![\w-])(.*)$/.exec(pathValue);
477
+ if (!legacy) return match;
478
+ const replacement = target === '/' && legacy[1].startsWith('/') ? legacy[1] : target + legacy[1];
479
+ if (double !== undefined) return prefix + JSON.stringify(replacement + tailSpace);
480
+ if (single !== undefined) return prefix + "'" + (replacement + tailSpace).replace(/'/g, "''") + "'";
481
+ const scalar = /[\s#:'"[\]{}&*!|>]/.test(replacement) ? JSON.stringify(replacement) : replacement;
482
+ return prefix + scalar + tailSpace;
483
+ });
484
+ }).join('\n');
498
485
  }
499
486
 
500
487
  // ---------------------------------------------------------------------------
@@ -554,6 +541,8 @@ function abbrev(filePath) {
554
541
 
555
542
  async function main() {
556
543
  const opts = parseArgs(process.argv);
544
+ const canonicalVaultPath = resolveHostPath('vault-dir', null, loadHostPaths());
545
+ let canonicalWarningEmitted = false;
557
546
 
558
547
  if (opts.help) {
559
548
  printHelp();
@@ -659,7 +648,7 @@ async function main() {
659
648
  totalScanned++;
660
649
  // Two independent drift classes, each discovered per-line:
661
650
  // - OLD_SEGMENT username drift → rewriteContent (split+join)
662
- // - missing /Bernhard/ segment → rewriteMissingSegment (#600 D3)
651
+ // - legacy vault base → rewriteMissingSegment (#600 D3)
663
652
  const hits = findHits(filePath, content);
664
653
  const missingHits = findMissingSegmentHits(filePath, content);
665
654
  if (hits.length === 0 && missingHits.length === 0) continue;
@@ -669,7 +658,15 @@ async function main() {
669
658
  // in mixed files are rare; if any exist, classify each line and skip-count.
670
659
  const fixableHits = hits.filter((h) => h.classification !== 'historical');
671
660
  const historicalHits = hits.filter((h) => h.classification === 'historical');
672
- const fixableMissing = missingHits.filter((h) => h.classification !== 'historical');
661
+ const pendingMissing = missingHits.filter((h) => h.classification !== 'historical');
662
+ if (pendingMissing.length && !canonicalVaultPath && !canonicalWarningEmitted) {
663
+ process.stderr.write('migrate-vault-paths: WARN legacy vault targets skipped — set SO_VAULT_DIR or owner.yaml paths.vault-dir\n');
664
+ canonicalWarningEmitted = true;
665
+ }
666
+ const fixableMissing = pendingMissing.filter((h) => rewriteMissingSegment(h.text, h.text, canonicalVaultPath) !== h.text);
667
+ for (const h of pendingMissing.filter((hit) => !fixableMissing.includes(hit))) {
668
+ emit(opts, { action:'skipped', reason: canonicalVaultPath ? 'canonical-target-unchanged-or-invalid' : 'canonical-vault-unconfigured', file:filePath, line:h.line, classification:h.classification });
669
+ }
673
670
  const historicalMissing = missingHits.filter((h) => h.classification === 'historical');
674
671
 
675
672
  for (const h of [...historicalHits, ...historicalMissing]) {
@@ -695,7 +692,7 @@ async function main() {
695
692
  // touches a line the username rewrite already owns (see rewriteMissingSegment).
696
693
  let newContent = content;
697
694
  if (fixableHits.length > 0) newContent = rewriteContent(newContent);
698
- if (fixableMissing.length > 0) newContent = rewriteMissingSegment(newContent, content);
695
+ if (fixableMissing.length > 0) newContent = rewriteMissingSegment(newContent, content, canonicalVaultPath);
699
696
 
700
697
  for (const h of fixableHits) {
701
698
  totalLinesFixed++;
@@ -23,7 +23,7 @@
23
23
  // + Unreleased folded, drift sweep, tag collision
24
24
  // (local, origin, github), github/main mirror parity,
25
25
  // npm registry collision, npm token liveness, CI green
26
- // on HEAD, leakage gate over `npm pack --dry-run`.
26
+ // on HEAD, leakage gate over retained archive filenames and contents.
27
27
  // --publish Runs --check first, then token publish via temp
28
28
  // userconfig (NPM_TOKEN from .env.local). A
29
29
  // target-confirmed npm receipt is the irreversible
@@ -80,10 +80,14 @@ import {
80
80
  chmodSync,
81
81
  rmSync,
82
82
  realpathSync,
83
+ mkdirSync,
84
+ lstatSync,
83
85
  } from 'node:fs';
84
- import { join, relative as relativePath, sep } from 'node:path';
86
+ import { join, relative as relativePath, sep, basename, isAbsolute } from 'node:path';
85
87
  import { tmpdir } from 'node:os';
86
88
  import { spawnSync } from 'node:child_process';
89
+ import { createHash } from 'node:crypto';
90
+ import { packedFilename } from './lib/plugin-package-stage.mjs';
87
91
  import { parseArgs } from 'node:util';
88
92
  import { fileURLToPath } from 'node:url';
89
93
 
@@ -828,6 +832,80 @@ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } =
828
832
  : { ok: true, detail: `${entries} packed entries, 0 leaks` };
829
833
  }
830
834
 
835
+ /** Strict npm 11/12 inventory, preserving the existing filename policy/floor. */
836
+ export function evaluatePackedInventory(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
837
+ if (pack.status !== 0) return { ok: false, detail: 'npm pack failed' };
838
+ try {
839
+ const json = JSON.parse(pack.stdout);
840
+ const records = Array.isArray(json) ? json : json && typeof json === 'object' ? Object.values(json) : [];
841
+ if (records.length !== 1) throw new Error('inventory-shape');
842
+ const record = records[0];
843
+ const filename = packedFilename([record]);
844
+ if (!filename || basename(filename) !== filename || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(filename)) throw new Error('archive-name');
845
+ if (!Array.isArray(record.files) || record.files.length > 20000 || record.files.length < minEntries) throw new Error('inventory-floor');
846
+ const paths = record.files.map((entry) => entry?.path);
847
+ if (paths.some((name) => typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => !part || part === '.' || part === '..') || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127))) || new Set(paths).size !== paths.length) throw new Error('inventory-path');
848
+ const violations = checkLeakage(paths.map((name) => `npm notice 1B ${name}`));
849
+ if (violations.length) return { ok: false, detail: 'forbidden packed filename class: ' + [...new Set(violations.map((v) => v.name))].join(', ') };
850
+ return { ok: true, detail: `${paths.length} packed entries, 0 filename leaks`, record, filename, paths };
851
+ } catch { return { ok: false, detail: 'invalid packed inventory or insufficient entry count' }; }
852
+ }
853
+
854
+ function archiveDigest(tarballPath) {
855
+ if (!lstatSync(tarballPath).isFile()) throw new Error('checked archive is not a regular file');
856
+ return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
857
+ }
858
+
859
+ /**
860
+ * Scan extracted archive bytes, retain that archive for the callback, and always
861
+ * clean up. Command/scan failures never call the publication callback. The
862
+ * 20,000-entry inventory ceiling matches the scanner; revisit for larger packs.
863
+ */
864
+ export async function withCheckedPackage(repoRoot, callback, {
865
+ runImpl = run, env = process.env, minEntries = MIN_PACKED_ENTRIES, cleanupImpl = rmSync,
866
+ } = {}) {
867
+ const work = mkdtempSync(join(tmpdir(), 'so-release-pack-'));
868
+ try {
869
+ let artifact;
870
+ let detail;
871
+ try {
872
+ const opts = { cwd: repoRoot, env: { ...env, npm_config_loglevel: 'notice' }, timeout: 60000 };
873
+ const pack = runImpl('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], opts);
874
+ const inventory = evaluatePackedInventory(pack, { minEntries });
875
+ if (!inventory.ok) return inventory;
876
+ const tarballPath = join(work, inventory.filename);
877
+ const digest = archiveDigest(tarballPath);
878
+ // Refuse links/special members BEFORE extraction and check the file census.
879
+ const members = runImpl('tar', ['-tzf', tarballPath], opts);
880
+ const types = runImpl('tar', ['-tvzf', tarballPath], opts);
881
+ if (members.status !== 0 || types.status !== 0 || types.stdout.trim().split('\n').some((line) => !/^[-d]/.test(line))) throw new Error('archive-list');
882
+ const names = members.stdout.trim().split('\n');
883
+ const files = names.filter((name) => !name.endsWith('/'));
884
+ const expected = new Set(inventory.paths.map((name) => `package/${name}`));
885
+ if (names.some((name) => !name.startsWith('package/') || name.split('/').some((part) => part === '..' || part === '.')) || files.length !== expected.size || new Set(files).size !== files.length || files.some((name) => !expected.has(name))) throw new Error('archive-census');
886
+ const unpacked = join(work, 'unpacked');
887
+ mkdirSync(unpacked);
888
+ const extracted = runImpl('tar', ['-xzf', tarballPath, '-C', unpacked], opts);
889
+ if (extracted.status !== 0) throw new Error('archive-extraction');
890
+ const inventoryPath = join(work, 'inventory.json');
891
+ writeFileSync(inventoryPath, JSON.stringify([inventory.record]), { mode: 0o600 });
892
+ const scanner = fileURLToPath(new URL('./lib/validate/check-owner-leakage.mjs', import.meta.url));
893
+ const scan = runImpl(process.execPath, [scanner, join(unpacked, 'package'), '--require-owner-patterns', '--packed-files', inventoryPath], opts);
894
+ const summaries = [...(scan.stdout || '').matchAll(/^Results: (\d+) passed, (\d+) failed \((\d+) scanned files\)$/gm)];
895
+ if (scan.status !== 0 || summaries.length !== 1 || Number(summaries[0][1]) < 1 || Number(summaries[0][2]) !== 0 || Number(summaries[0][3]) !== inventory.paths.length) return { ok: false, detail: 'required owner policy / packed content scan failed or incomplete' };
896
+ if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
897
+ chmodSync(tarballPath, 0o400);
898
+ artifact = { tarballPath, digest };
899
+ detail = `${inventory.paths.length} packed files, filenames and contents verified`;
900
+ } catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
901
+ return { ok: true, detail, value: await callback(artifact) };
902
+ } finally {
903
+ // Cleanup must never replace an irreversible receipt or the original error.
904
+ try { cleanupImpl(work, { recursive: true, force: true }); }
905
+ catch { console.error('release: temporary package cleanup failed; release outcome unchanged'); }
906
+ }
907
+ }
908
+
831
909
  /**
832
910
  * Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
833
911
  *
@@ -1209,22 +1287,6 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
1209
1287
  add('ci-green-on-head', ciRows.gitlab.ok, ciRows.gitlab.detail);
1210
1288
  add('ci-green-on-head-github', ciRows.github.ok, ciRows.github.detail);
1211
1289
 
1212
- // 7. Leakage gate over the actual pack file list.
1213
- // `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
1214
- // file listing as `npm notice` lines. Any ancestor that ran under `npm run
1215
- // --silent` (the pre-push gate does exactly that) therefore hands this child a
1216
- // silent loglevel and the listing is EMPTY with exit 0 — measured: 818 notice
1217
- // lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
1218
- // turns that into a correct fail-closed verdict, but a red leakage row that
1219
- // means "we could not look" is not the row anyone reads it as. Pin the level
1220
- // rather than inherit it, so the gate's input never depends on its caller.
1221
- const pack = run('npm', ['pack', '--dry-run'], {
1222
- cwd: repoRoot,
1223
- env: { ...process.env, npm_config_loglevel: 'notice' },
1224
- });
1225
- const leakage = evaluateLeakageGate(pack);
1226
- add('leakage-gate', leakage.ok, leakage.detail);
1227
-
1228
1290
  return checks;
1229
1291
  }
1230
1292
 
@@ -1407,7 +1469,7 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1407
1469
  * reads as unconfirmed, `publish()` throws, and `main()` exits 2 = "pre-receipt,
1408
1470
  * safe to rerun" while the registry already holds the version. That is the worst
1409
1471
  * failure this file can produce, and it is the same inherited-silent trap the
1410
- * leakage gate's `npm pack --dry-run` was already pinned against (see preflight
1472
+ * leakage gate's `npm pack --json` was already pinned against (see preflight
1411
1473
  * step 7). The pin makes the receipt independent of the caller's environment.
1412
1474
  *
1413
1475
  * Exported because `publish()` itself deliberately is not (it is the
@@ -1418,10 +1480,11 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1418
1480
  * @param {string} userconfigPath — the 0600 temp npmrc carrying the token
1419
1481
  * @returns {{cmd: string, args: string[], opts: {cwd: string, env: object}}}
1420
1482
  */
1421
- export function publishInvocation(repoRoot, userconfigPath) {
1483
+ export function publishInvocation(repoRoot, userconfigPath, artifact) {
1484
+ if (!artifact?.tarballPath || !artifact.digest || archiveDigest(artifact.tarballPath) !== artifact.digest) throw new Error('refusing publish without unchanged checked archive');
1422
1485
  return {
1423
1486
  cmd: 'npm',
1424
- args: ['publish', '--access', 'public', '--userconfig', userconfigPath],
1487
+ args: ['publish', artifact.tarballPath, '--ignore-scripts', '--access', 'public', '--userconfig', userconfigPath],
1425
1488
  opts: { cwd: repoRoot, env: { ...process.env, npm_config_loglevel: 'notice' } },
1426
1489
  };
1427
1490
  }
@@ -1434,7 +1497,7 @@ function publish(repoRoot, target, deps = {}) {
1434
1497
  const token = loadNpmToken(repoRoot);
1435
1498
  const runImpl = deps.runImpl ?? run;
1436
1499
  const res = withTempUserconfig(token, (tmpRc) => {
1437
- const call = publishInvocation(repoRoot, tmpRc);
1500
+ const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
1438
1501
  return runImpl(call.cmd, call.args, call.opts);
1439
1502
  });
1440
1503
  const receipt = evaluatePublishReceipt(res, target);
@@ -1919,20 +1982,24 @@ async function main() {
1919
1982
  if (values.check || values.publish) {
1920
1983
  const target = readPackageVersion(repoRoot);
1921
1984
  const checks = await preflight(repoRoot, target, { skipCi: values['skip-ci'] });
1922
- const ok = printChecks(checks, values.json && !values.publish, target);
1923
- if (!ok) return 1;
1924
- if (!values.publish) return 0;
1925
-
1926
- console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
1927
- // No spawn timeout is set anywhere in this file, on purpose: a kill in the
1928
- // middle of `npm publish` or a tag push is the very failure the receipt
1929
- // boundary exists to avoid. The wall-clock cost is real, though — each of
1930
- // the two `git push` remotes re-runs the husky pre-push full gate (~2.5 min
1931
- // each, measured) and the site poll waits up to 120 s.
1932
- console.log(' This tail can run ~7 minutes (pre-push gate x2 remotes + up to 120s site poll).');
1933
- console.log(' Run it with a >=600s command timeout or in the background — do NOT kill it mid-run.');
1934
- const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
1935
- return printPublishOutcome(outcome, target);
1985
+ const checked = await withCheckedPackage(repoRoot, async (artifact) => {
1986
+ checks.push({ name: 'leakage-gate', ok: true, detail: 'packed filenames and required owner-policy content scan verified' });
1987
+ const ok = printChecks(checks, values.json && !values.publish, target);
1988
+ if (!ok) return 1;
1989
+ if (!values.publish) return 0;
1990
+ console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
1991
+ console.log(' Publish and post-receipt reconciliation may take several minutes; do not kill it mid-run.');
1992
+ const outcome = await runPublishRelease(repoRoot, target, {
1993
+ publishImpl: (root, version) => publish(root, version, { artifact }),
1994
+ });
1995
+ return printPublishOutcome(outcome, target);
1996
+ });
1997
+ if (!checked.ok) {
1998
+ checks.push({ name: 'leakage-gate', ok: false, detail: checked.detail });
1999
+ printChecks(checks, values.json && !values.publish, target);
2000
+ return 1;
2001
+ }
2002
+ return checked.value;
1936
2003
  }
1937
2004
 
1938
2005
  console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
@@ -3,7 +3,7 @@
3
3
  * vault-consolidate.mjs — One-shot vault consolidation migration (PRD F1.1, Issue #499).
4
4
  *
5
5
  * Folds the redundant vault at `~/Projects/vault/` into the canonical
6
- * `~/Projects/Bernhard/vault/`, with per-file conflict resolution, idempotent
6
+ * host-configured destination, with per-file conflict resolution, idempotent
7
7
  * re-runs, and a compressed backup of the source side BEFORE any write.
8
8
  *
9
9
  * Design — two-phase coordinator-driven flow for merge resolution
@@ -95,10 +95,10 @@ import {
95
95
  stageBackup,
96
96
  compressAndCleanupBackup,
97
97
  } from './lib/vault-consolidate-fs.mjs';
98
+ import { loadHostPaths, resolveHostPath } from './lib/config/host-paths.mjs';
98
99
  import { isMainModule } from './lib/is-main-module.mjs';
99
100
 
100
101
  const DEFAULT_SOURCE = '~/Projects/vault';
101
- const DEFAULT_CANONICAL = '~/Projects/Bernhard/vault';
102
102
 
103
103
  // ---------------------------------------------------------------------------
104
104
  // CLI parsing
@@ -122,7 +122,7 @@ Options:
122
122
  --source <path> Path to the redundant (source) vault.
123
123
  Default: ${DEFAULT_SOURCE}
124
124
  --canonical <path> Path to the canonical vault.
125
- Default: ${DEFAULT_CANONICAL}
125
+ Default: SO_VAULT_DIR or owner.yaml paths.vault-dir (required when source exists)
126
126
  --json Newline-delimited JSON output (one record per
127
127
  file, plus a final summary record).
128
128
  --resolve <rel>=<choice> Record a merge decision for the file at the
@@ -215,7 +215,9 @@ async function main() {
215
215
  const isJson = parsed.values.json === true;
216
216
 
217
217
  const sourceRoot = path.resolve(expandTilde(parsed.values.source ?? DEFAULT_SOURCE));
218
- const canonicalRoot = path.resolve(expandTilde(parsed.values.canonical ?? DEFAULT_CANONICAL));
218
+ const canonicalTarget = parsed.values.canonical ?? resolveHostPath('vault-dir', null, loadHostPaths());
219
+ const canonicalRoot = typeof canonicalTarget === 'string' && canonicalTarget.trim()
220
+ ? path.resolve(expandTilde(canonicalTarget.trim())) : null;
219
221
 
220
222
  // Parse --resolve flags into a Map<relPath, "src"|"dst"|"skip">
221
223
  const resolutions = new Map();
@@ -255,6 +257,11 @@ async function main() {
255
257
  process.exit(0);
256
258
  }
257
259
 
260
+ if (!canonicalRoot) {
261
+ process.stderr.write('ERROR: canonical vault is unconfigured — pass --canonical or set SO_VAULT_DIR / owner.yaml paths.vault-dir\n');
262
+ process.exit(1);
263
+ }
264
+
258
265
  if (!(await isDir(canonicalRoot))) {
259
266
  process.stderr.write(
260
267
  `ERROR: canonical vault not found at ${canonicalRoot} — refusing to consolidate\n`
@@ -262,6 +269,14 @@ async function main() {
262
269
  process.exit(2);
263
270
  }
264
271
 
272
+ if (await fs.realpath(sourceRoot) === await fs.realpath(canonicalRoot)) {
273
+ const summary = { kind:'summary', mode:isDryRun ? 'dry-run' : 'apply', source:sourceRoot,
274
+ canonical:canonicalRoot, counts:{copy:0,'skip-already-present':0,merge:0,'conflict-needs-review':0},
275
+ notice:'source and canonical resolve to the same vault — nothing to do' };
276
+ process.stdout.write(isJson ? JSON.stringify(summary) + '\n' : summary.notice + '\n');
277
+ process.exit(0);
278
+ }
279
+
265
280
  // -------------------------------------------------------------------------
266
281
  // Output helpers (close over isJson + the per-run records buffer)
267
282
  // -------------------------------------------------------------------------
@@ -34,7 +34,7 @@ PHASE 1 IMPLEMENTED (2026-04-19). Session-end opt-in quality gate. Upstream of `
34
34
 
35
35
  | # | Check | What it scans | How |
36
36
  |---|-------|---------------|-----|
37
- | 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)` |
37
+ | 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)`; missing paths under validated owner.yaml `drift-check.foreign-host-prefixes` are warnings (#937) |
38
38
  | 2 | `project-count-sync` | Hardcoded "N registered" / "N projects" claims next to `01-projects/` | compare to `ls -d 01-projects/*/` |
39
39
  | 3 | `issue-reference-freshness` | `#NN` in forward-looking sections (What's Next, Backlog, Open Issues, Offene Themen, Todo, Next Steps) | `glab issue view NN --repo <origin>` |
40
40
  | 4 | `session-file-existence` | `50-sessions/YYYY-MM-DD-*.md` references anywhere in scope | `existsSync(vault/50-sessions/<file>)` |