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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -2
- package/README.md +14 -10
- package/docs/USER-GUIDE.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +71 -1
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/scope-collision-guard.md +12 -2
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-bash-write-verify.mjs +38 -21
- package/package.json +2 -2
- package/scripts/emit-session.mjs +15 -0
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +32 -26
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-record-repair.mjs +19 -8
- package/scripts/lib/session-schema/validator.mjs +12 -0
- package/scripts/lib/session-start-probes.mjs +20 -5
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +107 -37
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/site-numbers.mjs +4 -0
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/_shared/fleet-protocol.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +14 -3
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +39 -9
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- 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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
429
|
-
//
|
|
430
|
-
ready:
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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`
|
|
411
|
-
*
|
|
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
|
-
*
|
|
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
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
// -
|
|
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
|
|
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++;
|