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.
- 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 +33 -2
- package/README.md +10 -9
- package/docs/USER-GUIDE.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +35 -5
- 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/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.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/scope-gate.mjs +68 -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/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +103 -36
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +14 -3
- package/skills/session-end/metrics-collection.md +26 -2
- 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:
|
|
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;
|
|
@@ -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++;
|
package/scripts/release.mjs
CHANGED
|
@@ -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
|
|
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 --
|
|
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
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
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>)` |
|