session-orchestrator 5.8.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 (52) 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 +52 -2
  6. package/README.md +14 -10
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +71 -1
  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/ci-status-banner.mjs +38 -5
  21. package/scripts/lib/events-schema.mjs +8 -0
  22. package/scripts/lib/gates/gate-full.mjs +6 -0
  23. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  24. package/scripts/lib/named-vault-resolver.mjs +1 -1
  25. package/scripts/lib/owner-yaml.mjs +33 -3
  26. package/scripts/lib/platform.mjs +70 -1
  27. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  28. package/scripts/lib/quality-gate-wave.mjs +40 -0
  29. package/scripts/lib/quality-gate.mjs +32 -26
  30. package/scripts/lib/redact-spans.mjs +8 -17
  31. package/scripts/lib/scope-gate.mjs +68 -5
  32. package/scripts/lib/session-record-repair.mjs +19 -8
  33. package/scripts/lib/session-schema/validator.mjs +12 -0
  34. package/scripts/lib/session-start-probes.mjs +20 -5
  35. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  36. package/scripts/lib/vault-backfill/template.mjs +1 -1
  37. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  38. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  39. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  40. package/scripts/lib/wave-resource-gate.mjs +2 -1
  41. package/scripts/migrate-vault-paths.mjs +57 -60
  42. package/scripts/release.mjs +107 -37
  43. package/scripts/run-quality-gate.mjs +9 -39
  44. package/scripts/site-numbers.mjs +4 -0
  45. package/scripts/vault-consolidate.mjs +19 -4
  46. package/skills/_shared/fleet-protocol.md +2 -0
  47. package/skills/claude-md-drift-check/SKILL.md +1 -1
  48. package/skills/claude-md-drift-check/checker.mjs +14 -3
  49. package/skills/remote-offload/SKILL.md +1 -1
  50. package/skills/session-end/metrics-collection.md +39 -9
  51. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  52. package/skills/wave-executor/references/wave-loop-review.md +37 -7
@@ -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;
@@ -412,6 +412,11 @@ export async function dispatchRemote(
412
412
  * `Ferdinands-Macbook-2 ready=yes · load 14.23 · mem free 96% · headless: slots
413
413
  * none, keychain-route ok · 9 jobs · claude procs 12`
414
414
  *
415
+ * New doctor lines split `gate-ready` and `headless-ready`; only the latter
416
+ * authorizes Claude dispatch. Legacy `ready` remains accepted without new markers.
417
+ * `load` also holds the new `load5 (5 min)` reading; `memFreePct` holds free
418
+ * (Darwin) or available (Linux) percent. No live new-format measurement is implied.
419
+ *
415
420
  * Every metric is `null` when its segment is absent — never a fabricated `0`,
416
421
  * which would read as "measured, idle" for a host that answered nothing.
417
422
  * @param {string} raw
@@ -420,19 +425,32 @@ export async function dispatchRemote(
420
425
  */
421
426
  export function parseDoctorLine(raw) {
422
427
  const text = String(raw ?? '');
423
- const num = (re) => {
428
+ // New-format markers suppress the legacy fallback, even if their value or
429
+ // key boundary is malformed. Conflicting/unknown headless values fail closed.
430
+ const key = /\b(?:gate-ready|headless-ready)\b/i.test(text) ? 'headless-ready' : 'ready';
431
+ const values = [...text.matchAll(new RegExp(`(?:^|\\s)${key}=([^\\s]*)`, 'gi'))];
432
+ const count = (re) => {
424
433
  const m = re.exec(text);
425
434
  return m ? Number(m[1]) : null;
426
435
  };
436
+ const segments = text.split(/[·\r\n]/).map((segment) => segment.trim());
437
+ const num = (re, max = Infinity) => {
438
+ const m = segments.map((segment) => re.exec(segment)).find(Boolean);
439
+ const value = m ? Number(m[1]) : NaN;
440
+ return Number.isFinite(value) && value <= max ? value : null;
441
+ };
427
442
  return {
428
- // `ready` is the ONLY decision field: anything that is not a literal
429
- // `ready=yes` is not-ready, including an unparseable line.
430
- ready: /\bready=yes\b/i.test(text),
443
+ // Claude dispatch uses headless readiness, independently of gate readiness.
444
+ // Only the old format falls back to an exact, standalone `ready=yes` token.
445
+ ready: values.length > 0 && values.every((m) => /^yes$/i.test(m[1])),
431
446
  raw: text,
432
- load: num(/\bload\s+([0-9]+(?:\.[0-9]+)?)/i),
433
- memFreePct: num(/\bmem free\s+([0-9]+(?:\.[0-9]+)?)\s*%/i),
434
- jobs: num(/\b([0-9]+)\s+jobs?\b/i),
435
- claudeProcs: num(/\bclaude procs\s+([0-9]+)/i),
447
+ load: num(/^load(?:5\s+\(5 min\))?\s+([0-9]+(?:\.[0-9]+)?)$/i),
448
+ // The existing field holds Darwin free % or Linux available %, never kB.
449
+ memFreePct:
450
+ num(/^mem (?:free|available)\s+([0-9]+(?:\.[0-9]+)?)\s*%$/i, 100) ??
451
+ num(/^mem available\s+[0-9]+\s+kB available\s*\/\s*[0-9]+\s+kB total\s+\(([0-9]+(?:\.[0-9]+)?)% available\)$/i, 100),
452
+ jobs: count(/\b([0-9]+)\s+jobs?\b/i),
453
+ claudeProcs: count(/\bclaude procs\s+([0-9]+)/i),
436
454
  };
437
455
  }
438
456
 
@@ -491,7 +509,7 @@ export function remoteDoctor({ host, execFn = execFileSync, timeoutMs = 90_000 }
491
509
  * @param {string} alias — declared host alias (`remote-hosts[].alias`).
492
510
  * @param {object} [opts]
493
511
  * @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
494
- * @returns {Promise<boolean>} true only on a literal `ready=yes` line.
512
+ * @returns {Promise<boolean>} true only for headless-ready=yes (legacy ready=yes without new markers).
495
513
  */
496
514
  export async function remoteReadyProbe(alias, { execFn } = {}) {
497
515
  try {
@@ -134,7 +134,8 @@ async function applyDecisionRules(measurements, opts) {
134
134
  * The gate does NOT probe the network — a placement decision must stay a pure
135
135
  * function of its inputs. The caller supplies a readiness WITNESS:
136
136
  * - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
137
- * SessionStart `Offload m5: ready=yes` banner or `remoteDoctor()`.
137
+ * SessionStart `Offload <alias>: headless-ready=yes` banner (legacy `ready=yes` only
138
+ * without new readiness markers) or `remoteDoctor().ready`.
138
139
  * - `opts.probeFn` — `async (alias) => boolean`, consulted only for hosts the
139
140
  * `remoteReady` map does not already answer for. Default `null`. A probeFn
140
141
  * that REJECTS is read as not-ready and its message is appended to
@@ -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++;