session-orchestrator 5.3.0 → 5.4.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -230,17 +230,19 @@ export class ValidationError extends Error {
|
|
|
230
230
|
* Throws ValidationError on contract violations. Does NOT mutate input.
|
|
231
231
|
*
|
|
232
232
|
* @param {object} entry — candidate learning
|
|
233
|
-
* @param {{ legacyTolerant?: boolean }} [opts] —
|
|
233
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — EventDrop #386. When `true`, a
|
|
234
234
|
* value `normalizeLearning()` already passes through UNCHECKED (or merely
|
|
235
235
|
* DEFAULTED, never validated) on read is no longer rejected here either:
|
|
236
236
|
* an out-of-enum `schema_version` (measured against the real EventDrop.at
|
|
237
237
|
* store: 3 live records carry `schema_version: 2`), the
|
|
238
238
|
* LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
|
|
239
239
|
* when NO `confidence` key is present, and the `scope`/`host_class`/
|
|
240
|
-
* `anonymized` shape checks (measured live: a record
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
240
|
+
* `anonymized`/`file_paths` shape checks (measured live: a record whose `scope` held a
|
|
241
|
+
* file path — e.g. a repo-relative component path such as
|
|
242
|
+
* `src/components/sample/sticky-cta.tsx` (the measured value is not
|
|
243
|
+
* reproduced here) — a producer bug that wrote a file path into the scope
|
|
244
|
+
* column, which `normalizeLearning` never validates and therefore never
|
|
245
|
+
* rejects). A field that IS present keeps
|
|
244
246
|
* being validated regardless when its DATA TYPE can be silently corrupted
|
|
245
247
|
* by a JSON round-trip (a present-but-malformed `confidence` still throws
|
|
246
248
|
* — see io.mjs #662): this option relaxes "you must HAVE a valid value",
|
|
@@ -296,9 +298,11 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
|
296
298
|
// scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
|
|
297
299
|
// absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
|
|
298
300
|
// PRESENT one, so a corrupt legacy value round-trips silently today
|
|
299
|
-
// (measured live in production: a record
|
|
300
|
-
//
|
|
301
|
-
//
|
|
301
|
+
// (measured live in production: a record whose `scope` held a file path —
|
|
302
|
+
// e.g. a repo-relative component path such as
|
|
303
|
+
// `src/components/sample/sticky-cta.tsx` (the measured value is not
|
|
304
|
+
// reproduced here) — evidently a producer bug that wrote a file path into
|
|
305
|
+
// the scope column). All three checks are
|
|
302
306
|
// therefore skipped under legacyTolerant. Unlike `confidence` (a number,
|
|
303
307
|
// where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
|
|
304
308
|
// across a JSON round-trip — the #662 concern), a string/string-or-null/
|
|
@@ -319,6 +323,24 @@ export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
|
319
323
|
throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// file_paths (GitLab #1447): when present, an array of repo-relative path
|
|
327
|
+
// strings — a bare string (`file_paths: "src/x.ts"`) is the producer slip
|
|
328
|
+
// this rejects; every reader iterates it as a list. `null` reads as absent,
|
|
329
|
+
// as in migrateLegacyLearning. Skipped under legacyTolerant like the shape
|
|
330
|
+
// checks above: the read funnel passes the value through unchecked.
|
|
331
|
+
const filePaths = entry.file_paths;
|
|
332
|
+
if (!legacyTolerant && filePaths !== undefined && filePaths !== null) {
|
|
333
|
+
if (!Array.isArray(filePaths)) {
|
|
334
|
+
throw new ValidationError(`file_paths must be an array of repo-relative paths, got: ${typeof filePaths}`);
|
|
335
|
+
}
|
|
336
|
+
const badIndex = filePaths.findIndex((p) => !isRepoRelativePathEntry(p));
|
|
337
|
+
if (badIndex !== -1) {
|
|
338
|
+
throw new ValidationError(
|
|
339
|
+
`file_paths[${badIndex}] must be a non-empty repo-relative path (not absolute, no '..' segment), got: ${JSON.stringify(filePaths[badIndex])}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
322
344
|
// Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
|
|
323
345
|
// `scope` (garbage or otherwise) is by definition not `'public'`, so this
|
|
324
346
|
// pair only ever fires for a record that genuinely claims `scope: 'public'`
|
|
@@ -476,6 +498,85 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
|
|
|
476
498
|
return out;
|
|
477
499
|
}
|
|
478
500
|
|
|
501
|
+
/**
|
|
502
|
+
* File extensions a path-like `scope` value must end in to be treated as a
|
|
503
|
+
* repo-relative file path (GitHub #69 / GitLab #1446). Deliberately a closed
|
|
504
|
+
* list: a looser "contains a slash and a dot" test would also swallow values
|
|
505
|
+
* such as `infrastructure/docker-compose` or `node.js`.
|
|
506
|
+
*/
|
|
507
|
+
const PATH_SCOPE_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|mts|cts|json|jsonl|md|mdx|ya?ml|toml|css|scss|html|vue|svelte|astro|py|rb|go|rs|swift|kt|java|sh|sql|prisma)$/i;
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Strict predicate: is `s` a repo-relative file path (at least one directory
|
|
511
|
+
* segment, no `..`, no empty segment, a known source/doc extension)? Rejects
|
|
512
|
+
* canonical scopes, absolute and home-relative paths, Windows drive paths,
|
|
513
|
+
* URLs, and anything containing whitespace.
|
|
514
|
+
*
|
|
515
|
+
* @param {unknown} s — candidate `scope` value
|
|
516
|
+
* @returns {boolean}
|
|
517
|
+
*/
|
|
518
|
+
function looksLikeRepoRelativePath(s) {
|
|
519
|
+
if (typeof s !== 'string' || VALID_SCOPES.includes(s) || s.length > 512) return false;
|
|
520
|
+
if (/\s/.test(s) || /^[/~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
|
|
521
|
+
const segs = s.split('/');
|
|
522
|
+
if (segs.length < 2 || segs.includes('..') || segs.includes('')) return false;
|
|
523
|
+
return PATH_SCOPE_EXT.test(segs[segs.length - 1]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Loose predicate for ONE `file_paths` entry (GitLab #1447): a non-empty string
|
|
528
|
+
* that is not absolute (`/`, `\`, `~`, a Windows drive), not a URL, and has no
|
|
529
|
+
* `..` segment. Deliberately looser than {@link looksLikeRepoRelativePath}:
|
|
530
|
+
* root-level files (`package.json`), directories (`docs/prd`) and globs
|
|
531
|
+
* (`skills/**`) are legitimate `file_paths` values.
|
|
532
|
+
*
|
|
533
|
+
* Exported because `scripts/memory-propose.mjs` gates `--file-paths` on the same
|
|
534
|
+
* rule: a proposal this predicate rejects would be queued, approved, and then
|
|
535
|
+
* refused by {@link validateLearning} at write time.
|
|
536
|
+
*
|
|
537
|
+
* @param {unknown} s — candidate entry
|
|
538
|
+
* @returns {boolean}
|
|
539
|
+
*/
|
|
540
|
+
export function isRepoRelativePathEntry(s) {
|
|
541
|
+
if (typeof s !== 'string' || s.trim() === '') return false;
|
|
542
|
+
if (/^[/\\~]/.test(s) || /^[A-Za-z]:[\\/]/.test(s) || s.includes('://')) return false;
|
|
543
|
+
return !s.split(/[\\/]/).includes('..');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const LEADING_DOT_SLASH = /^(?:\.\/)+/;
|
|
547
|
+
|
|
548
|
+
/** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'local'`. */
|
|
549
|
+
const LOCAL_ALIAS_SCOPES = new Set(['vault-tools', 'deep-sessions', 'wave-executor', 'coordinator']);
|
|
550
|
+
|
|
551
|
+
/** Non-canonical STRING scopes {@link migrateLegacyLearning} coerces to `'private'`. */
|
|
552
|
+
const PRIVATE_ALIAS_SCOPES = new Set(['project', 'repo']);
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Every word the migration reads as scope vocabulary. An array-scope entry
|
|
556
|
+
* equal to one of these is a scope word, never a path — the same reading the
|
|
557
|
+
* string branch gives it, so `['project']` is not turned into
|
|
558
|
+
* `file_paths: ['project']`.
|
|
559
|
+
*/
|
|
560
|
+
const SCOPE_WORDS = new Set([...VALID_SCOPES, ...LOCAL_ALIAS_SCOPES, ...PRIVATE_ALIAS_SCOPES]);
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The repo-relative paths a broken `scope` value carries, or `null` when it is
|
|
564
|
+
* not a path scope. A single string must pass the strict
|
|
565
|
+
* {@link looksLikeRepoRelativePath} (a lone string may be a scope word); an
|
|
566
|
+
* ARRAY is never scope vocabulary, so the loose per-entry predicate suffices
|
|
567
|
+
* there — an empty array yields `[]`. A leading `./` run is stripped from each
|
|
568
|
+
* path; case is kept (git paths are case-sensitive).
|
|
569
|
+
*
|
|
570
|
+
* @param {unknown} scope
|
|
571
|
+
* @returns {string[] | null}
|
|
572
|
+
*/
|
|
573
|
+
function pathScopeEntries(scope) {
|
|
574
|
+
if (looksLikeRepoRelativePath(scope)) return [scope.replace(LEADING_DOT_SLASH, '')];
|
|
575
|
+
if (!Array.isArray(scope)) return null;
|
|
576
|
+
const entries = scope.map((e) => (typeof e === 'string' ? e.replace(LEADING_DOT_SLASH, '') : e));
|
|
577
|
+
return entries.every((e) => isRepoRelativePathEntry(e) && !SCOPE_WORDS.has(e)) ? entries : null;
|
|
578
|
+
}
|
|
579
|
+
|
|
479
580
|
/**
|
|
480
581
|
* Migrate a legacy learning record to the canonical schema_version:1 shape.
|
|
481
582
|
* Idempotent — calling it on an already-canonical record is a safe no-op.
|
|
@@ -483,9 +584,32 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
|
|
|
483
584
|
* Alias precedence for insight: insight > description > recommendation > observation > lesson
|
|
484
585
|
*
|
|
485
586
|
* Producer dialects (`files`→`file_paths`, duplicate `session_id`, `last_seen`,
|
|
486
|
-
* `next_review: null`) are normalized by {@link normalizeDialects}
|
|
487
|
-
*
|
|
488
|
-
*
|
|
587
|
+
* `next_review: null`) are normalized by {@link normalizeDialects}. Timestamp
|
|
588
|
+
* FORMAT is deliberately NOT reformatted here (byte-exact contract preserved);
|
|
589
|
+
* the read funnel + backfill canonicalize timestamps.
|
|
590
|
+
*
|
|
591
|
+
* Broken-consumer-record coercions (GitHub #69 / GitLab #1446 — measured in a
|
|
592
|
+
* consumer store: 124 of 307 records failed strict validateLearning, 76 on
|
|
593
|
+
* `scope`, 48 on `schema_version`). Applied AFTER normalizeDialects, because
|
|
594
|
+
* coercing a path-like scope before the `files`→`file_paths` move would lose
|
|
595
|
+
* the legacy `files` data. Only this MIGRATION path coerces — the read funnel
|
|
596
|
+
* (normalizeLearning / normalizeDialects) stays pass-through by design:
|
|
597
|
+
* - path-like `scope` (a repo-relative file path a producer wrote into the
|
|
598
|
+
* scope column, see {@link looksLikeRepoRelativePath}) → appended to
|
|
599
|
+
* `file_paths` (copied, never mutated in place; created when absent/null;
|
|
600
|
+
* no duplicate) and `scope: 'private'`. A present non-array `file_paths`
|
|
601
|
+
* leaves the record untouched — it stays invalid and is counted. A
|
|
602
|
+
* leading `./` run is stripped first (GitLab #1447).
|
|
603
|
+
* - array-typed `scope` (GitLab #1447) → the same move when every entry is a
|
|
604
|
+
* repo-relative path ({@link isRepoRelativePathEntry}: root files, dirs and
|
|
605
|
+
* globs included); an empty array → `scope: 'private'`. An array holding an
|
|
606
|
+
* absolute path, a non-string or a scope word — canonical or one of the
|
|
607
|
+
* aliases below, e.g. `['project']` — stays untouched (invalid, counted);
|
|
608
|
+
* nothing is ever dropped.
|
|
609
|
+
* - `scope: 'project'` / `scope: 'repo'` → `'private'`.
|
|
610
|
+
* - `schema_version: '1'` (string) or `2` (number) → `1`.
|
|
611
|
+
* Each rule's output never re-matches that rule, so the whole function stays
|
|
612
|
+
* idempotent.
|
|
489
613
|
*
|
|
490
614
|
* The caller MUST still run validateLearning() on the result to confirm the
|
|
491
615
|
* migrated record passes the full schema gate before writing.
|
|
@@ -534,8 +658,7 @@ export function migrateLegacyLearning(entry) {
|
|
|
534
658
|
out.evidence = '';
|
|
535
659
|
}
|
|
536
660
|
|
|
537
|
-
|
|
538
|
-
if (out.scope && !VALID_SCOPES.includes(out.scope) && COERCIBLE_SCOPES.has(out.scope)) {
|
|
661
|
+
if (out.scope && !VALID_SCOPES.includes(out.scope) && LOCAL_ALIAS_SCOPES.has(out.scope)) {
|
|
539
662
|
out.scope = 'local';
|
|
540
663
|
}
|
|
541
664
|
|
|
@@ -560,12 +683,37 @@ export function migrateLegacyLearning(entry) {
|
|
|
560
683
|
out.schema_version = CURRENT_SCHEMA_VERSION;
|
|
561
684
|
}
|
|
562
685
|
|
|
563
|
-
// Normalize producer dialects
|
|
686
|
+
// Normalize producer dialects (files→file_paths, session_id/last_seen/
|
|
564
687
|
// next_review reconciliation) — schema_version is preserved by normalizeDialects.
|
|
565
688
|
// reserializeTimestamps:false keeps migrateLegacyLearning's byte-exact timestamp
|
|
566
689
|
// contract (it does schema/alias migration, not timestamp reformatting). The
|
|
567
690
|
// READ funnel (normalizeLearning) and the backfill canonicalize timestamp format.
|
|
568
|
-
|
|
691
|
+
const migrated = normalizeDialects(out, { reserializeTimestamps: false });
|
|
692
|
+
|
|
693
|
+
// Broken-consumer-record coercions (GitHub #69 / GitLab #1446, #1447) — AFTER
|
|
694
|
+
// normalizeDialects so a legacy `files` list has already become `file_paths`.
|
|
695
|
+
const pathEntries = pathScopeEntries(migrated.scope);
|
|
696
|
+
if (pathEntries !== null) {
|
|
697
|
+
const existing = migrated.file_paths;
|
|
698
|
+
if (existing === undefined || existing === null || Array.isArray(existing)) {
|
|
699
|
+
// Copy: normalizeDialects' shallow copy still shares the caller's array.
|
|
700
|
+
const filePaths = Array.isArray(existing) ? [...existing] : [];
|
|
701
|
+
for (const p of pathEntries) {
|
|
702
|
+
if (!filePaths.includes(p)) filePaths.push(p);
|
|
703
|
+
}
|
|
704
|
+
if (filePaths.length > 0) migrated.file_paths = filePaths;
|
|
705
|
+
migrated.scope = 'private';
|
|
706
|
+
}
|
|
707
|
+
// Present but not an array → leave untouched; the record stays invalid.
|
|
708
|
+
} else if (PRIVATE_ALIAS_SCOPES.has(migrated.scope)) {
|
|
709
|
+
migrated.scope = 'private';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (migrated.schema_version === '1' || migrated.schema_version === 2) {
|
|
713
|
+
migrated.schema_version = CURRENT_SCHEMA_VERSION;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return migrated;
|
|
569
717
|
}
|
|
570
718
|
|
|
571
719
|
// Module-level dedupe sets for warnings (per-process).
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* 4-5. Before this module, the `effective-sizing` analyzer
|
|
10
10
|
* (`skills/evolve/references/evolve-analyze-mode.md`) keyed its subject on
|
|
11
11
|
* `session_type` alone (e.g. `deep-session-sizing`), so a 7-wave ultradeep
|
|
12
|
-
* session and a 5-wave deep session landed on the SAME row — the
|
|
13
|
-
*
|
|
12
|
+
* session and a 5-wave deep session landed on the SAME row — the agents-per-wave
|
|
13
|
+
* average for one profile silently absorbed the other's.
|
|
14
14
|
*
|
|
15
15
|
* Pure, stdlib-only, no imports, no clock, no fs.
|
|
16
16
|
*
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* during a wave, reviewed by the memory-cleanup flow, and either accepted
|
|
8
8
|
* (appended to learnings.jsonl) or discarded.
|
|
9
9
|
*
|
|
10
|
-
* Pure
|
|
10
|
+
* Pure module — imports only node:crypto and the pure learnings/schema.mjs
|
|
11
|
+
* (type registry, repo-relative path predicate). No I/O, no quota enforcement
|
|
11
12
|
* (see store.mjs for those responsibilities).
|
|
12
13
|
*
|
|
13
14
|
* Canonical schema (schema_version: 1) — ALL required fields:
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
import { randomUUID } from 'node:crypto';
|
|
33
|
-
|
|
34
|
+
|
|
35
|
+
import { isRepoRelativePathEntry, LEARNING_TYPE_REGISTRY } from '../learnings/schema.mjs';
|
|
34
36
|
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
36
38
|
// Constants
|
|
@@ -251,24 +253,28 @@ export function validateProposalRecord(record) {
|
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
// file_paths (optional, but if present must be a non-empty array of
|
|
254
|
-
//
|
|
256
|
+
// repo-relative, newline-free, glob-metacharacter-free strings) — issue #900 C,
|
|
255
257
|
// Q3-MED fix pass: a glob metacharacter (* ? [ ] { }) surviving this schema
|
|
256
258
|
// gate would later reach emitter.mjs::globsFromFilePaths verbatim for a
|
|
257
259
|
// top-level (dirname==='.') entry, effectively producing an always-on rule
|
|
258
260
|
// glob (e.g. `file_paths: ['**']`). `createProposalRecord` never sets an
|
|
259
261
|
// empty array (it omits the key), so a present-but-empty `[]` is itself a
|
|
260
262
|
// signal of a malformed/tampered record — reject it explicitly.
|
|
263
|
+
// Repo-relative is the SAME predicate validateLearning applies at write time
|
|
264
|
+
// (isRepoRelativePathEntry): a record read back from proposals.jsonl is not
|
|
265
|
+
// re-validated before writeApproved → appendLearning, so an absolute, home-
|
|
266
|
+
// relative or URL entry must be refused here, not silently at promotion.
|
|
261
267
|
if ('file_paths' in record) {
|
|
262
268
|
const fp = record.file_paths;
|
|
263
269
|
const isValidArray =
|
|
264
270
|
Array.isArray(fp) &&
|
|
265
271
|
fp.length > 0 &&
|
|
266
272
|
fp.every(
|
|
267
|
-
(p) =>
|
|
273
|
+
(p) => isRepoRelativePathEntry(p) && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(p),
|
|
268
274
|
);
|
|
269
275
|
if (!isValidArray) {
|
|
270
276
|
errors.push(
|
|
271
|
-
'file_paths must be a non-empty array of
|
|
277
|
+
'file_paths must be a non-empty array of repo-relative, newline-free, glob-metacharacter-free strings when present',
|
|
272
278
|
);
|
|
273
279
|
}
|
|
274
280
|
}
|
|
@@ -29,11 +29,21 @@
|
|
|
29
29
|
* also YIELDS to tier 1: when SO_BASELINE_PATH is set (non-blank), resolveNamedBaseline
|
|
30
30
|
* returns the null-fallback ({source:null}) so the caller's env tier wins.
|
|
31
31
|
*
|
|
32
|
+
* ── Other sections (agents/vault#319) ────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* The same directory-prefix match serves any owner.yaml list of the shape
|
|
35
|
+
* `{ path, match: { path-prefix } }`. `section` picks the list and `envKey` the
|
|
36
|
+
* env var this tier yields to. The live second consumer is `vault-dirs:`
|
|
37
|
+
* (section 'vault-dirs', envKey 'SO_VAULT_DIR'), driven by
|
|
38
|
+
* `resolveVaultDir` in scripts/lib/config/host-paths.mjs. `name` is required
|
|
39
|
+
* only for `baselines:` (the #819 contract); elsewhere it is optional and
|
|
40
|
+
* defaults to `path`, which is what the ambiguity WARN then names.
|
|
41
|
+
*
|
|
32
42
|
* ── Exports ──────────────────────────────────────────────────────────────────
|
|
33
43
|
*
|
|
34
|
-
* parseBaselines(ownerConfig)
|
|
35
|
-
* matchBaselineForPath(absPath, baselines)
|
|
36
|
-
* resolveNamedBaseline({ cwd, ownerConfig, env })
|
|
44
|
+
* parseBaselines(ownerConfig, section?)
|
|
45
|
+
* matchBaselineForPath(absPath, baselines, section?)
|
|
46
|
+
* resolveNamedBaseline({ cwd, ownerConfig, env, section?, envKey? })
|
|
37
47
|
*/
|
|
38
48
|
|
|
39
49
|
import { normalize, sep } from 'node:path';
|
|
@@ -43,6 +53,9 @@ import { expandTilde } from './common.mjs';
|
|
|
43
53
|
// Internal helpers
|
|
44
54
|
// ---------------------------------------------------------------------------
|
|
45
55
|
|
|
56
|
+
/** Sections whose entries MUST carry a `name` (the #819 baselines contract). */
|
|
57
|
+
const NAME_REQUIRED_SECTIONS = new Set(['baselines']);
|
|
58
|
+
|
|
46
59
|
/** @param {unknown} v */
|
|
47
60
|
function isPlainObject(v) {
|
|
48
61
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -70,18 +83,23 @@ function _normalizePath(p) {
|
|
|
70
83
|
// ---------------------------------------------------------------------------
|
|
71
84
|
|
|
72
85
|
/**
|
|
73
|
-
* Extract and validate
|
|
86
|
+
* Extract and validate a directory-prefix list (default `baselines:`) from a raw
|
|
87
|
+
* ownerConfig object.
|
|
74
88
|
*
|
|
75
89
|
* Drop-and-WARN on malformed entries; never throw. Returns [] when the section
|
|
76
|
-
* is absent, null, or empty — the backward-compat no-op path.
|
|
90
|
+
* is absent, null, or empty — the backward-compat no-op path. Every WARN names
|
|
91
|
+
* the section it came from.
|
|
77
92
|
*
|
|
78
|
-
* Each valid entry: { name: string, path: string, match: { 'path-prefix': string } }
|
|
93
|
+
* Each valid entry: { name: string, path: string, match: { 'path-prefix': string } }.
|
|
94
|
+
* `name` is required for `baselines`; for other sections an absent name falls
|
|
95
|
+
* back to `path` (a present-but-blank or non-string name is still dropped).
|
|
79
96
|
*
|
|
80
97
|
* @param {object|undefined} ownerConfig — raw parsed owner.yaml (NOT merged with defaults)
|
|
98
|
+
* @param {string} [section='baselines'] — owner.yaml list to read (e.g. 'vault-dirs')
|
|
81
99
|
* @returns {Array<{name:string, path:string, match:{'path-prefix':string}}>}
|
|
82
100
|
*/
|
|
83
|
-
export function parseBaselines(ownerConfig) {
|
|
84
|
-
const raw = ownerConfig?.
|
|
101
|
+
export function parseBaselines(ownerConfig, section = 'baselines') {
|
|
102
|
+
const raw = ownerConfig?.[section];
|
|
85
103
|
|
|
86
104
|
// Absent or explicit null/empty → no-op
|
|
87
105
|
if (raw === undefined || raw === null) return [];
|
|
@@ -89,54 +107,56 @@ export function parseBaselines(ownerConfig) {
|
|
|
89
107
|
|
|
90
108
|
if (!Array.isArray(raw)) {
|
|
91
109
|
process.stderr.write(
|
|
92
|
-
|
|
110
|
+
`WARN named-baseline-resolver: owner.yaml ${section}: must be an array; ignoring\n`,
|
|
93
111
|
);
|
|
94
112
|
return [];
|
|
95
113
|
}
|
|
96
114
|
|
|
115
|
+
const nameRequired = NAME_REQUIRED_SECTIONS.has(section);
|
|
97
116
|
const result = [];
|
|
98
117
|
for (let i = 0; i < raw.length; i++) {
|
|
99
118
|
const entry = raw[i];
|
|
100
119
|
if (!isPlainObject(entry)) {
|
|
101
120
|
process.stderr.write(
|
|
102
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
121
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}] is not an object; dropping\n`,
|
|
103
122
|
);
|
|
104
123
|
continue;
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
const { name, path: entryPath, match } = entry;
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
const nameAbsent = name === undefined || name === null;
|
|
129
|
+
if ((nameRequired || !nameAbsent) && (typeof name !== 'string' || name.trim() === '')) {
|
|
110
130
|
process.stderr.write(
|
|
111
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
131
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].name must be a non-empty string; dropping entry\n`,
|
|
112
132
|
);
|
|
113
133
|
continue;
|
|
114
134
|
}
|
|
115
135
|
if (typeof entryPath !== 'string' || entryPath.trim() === '') {
|
|
116
136
|
process.stderr.write(
|
|
117
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
137
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].path must be a non-empty string; dropping entry\n`,
|
|
118
138
|
);
|
|
119
139
|
continue;
|
|
120
140
|
}
|
|
121
141
|
|
|
122
|
-
// match is REQUIRED (unlike vaults) —
|
|
142
|
+
// match is REQUIRED (unlike vaults) — an entry with no path-prefix can
|
|
123
143
|
// never be selected, so it is dropped rather than kept.
|
|
124
144
|
if (!isPlainObject(match)) {
|
|
125
145
|
process.stderr.write(
|
|
126
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
146
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].match must be an object; dropping entry\n`,
|
|
127
147
|
);
|
|
128
148
|
continue;
|
|
129
149
|
}
|
|
130
150
|
const pathPrefix = match['path-prefix'];
|
|
131
151
|
if (typeof pathPrefix !== 'string' || pathPrefix.trim() === '') {
|
|
132
152
|
process.stderr.write(
|
|
133
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
153
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].match.path-prefix must be a non-empty string; dropping entry\n`,
|
|
134
154
|
);
|
|
135
155
|
continue;
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
result.push({
|
|
139
|
-
name: name.trim(),
|
|
159
|
+
name: nameAbsent ? entryPath.trim() : name.trim(),
|
|
140
160
|
path: entryPath.trim(),
|
|
141
161
|
match: { 'path-prefix': pathPrefix.trim() },
|
|
142
162
|
});
|
|
@@ -162,9 +182,10 @@ export function parseBaselines(ownerConfig) {
|
|
|
162
182
|
*
|
|
163
183
|
* @param {string} absPath — the working directory to classify
|
|
164
184
|
* @param {Array<{name:string, path:string, match:{'path-prefix':string}}>} baselines
|
|
185
|
+
* @param {string} [section='baselines'] — owner.yaml list name, used only in the WARN
|
|
165
186
|
* @returns {{name:string, path:string, match:{'path-prefix':string}}|null}
|
|
166
187
|
*/
|
|
167
|
-
export function matchBaselineForPath(absPath, baselines) {
|
|
188
|
+
export function matchBaselineForPath(absPath, baselines, section = 'baselines') {
|
|
168
189
|
if (!absPath || !Array.isArray(baselines) || baselines.length === 0) return null;
|
|
169
190
|
|
|
170
191
|
const target = _normalizePath(absPath);
|
|
@@ -181,7 +202,7 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
181
202
|
|
|
182
203
|
if (matched.length > 1) {
|
|
183
204
|
process.stderr.write(
|
|
184
|
-
`WARN named-baseline-resolver: multiple
|
|
205
|
+
`WARN named-baseline-resolver: multiple ${section} match path "${absPath}": ${matched
|
|
185
206
|
.map((b) => b.name)
|
|
186
207
|
.join(', ')}; using first match "${matched[0].name}"\n`,
|
|
187
208
|
);
|
|
@@ -195,13 +216,13 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
195
216
|
// ---------------------------------------------------------------------------
|
|
196
217
|
|
|
197
218
|
/**
|
|
198
|
-
* Resolve the
|
|
219
|
+
* Resolve the directory-prefix match tier (default `baselines:`) for a given cwd.
|
|
199
220
|
*
|
|
200
221
|
* Returns:
|
|
201
|
-
* { path, name, source:'match' } when
|
|
222
|
+
* { path, name, source:'match' } when an entry's directory-prefix matches cwd.
|
|
202
223
|
* { path:null, name:null, source:null } (the null-fallback) otherwise — including
|
|
203
|
-
* when
|
|
204
|
-
*
|
|
224
|
+
* when `env[envKey]` is set (this tier yields to the higher env tier the caller
|
|
225
|
+
* owns), when the section is not configured, or when nothing matches.
|
|
205
226
|
*
|
|
206
227
|
* Pure + synchronous — no git calls, no disk reads. Never throws.
|
|
207
228
|
*
|
|
@@ -209,22 +230,26 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
209
230
|
* cwd?: string,
|
|
210
231
|
* ownerConfig?: object,
|
|
211
232
|
* env?: Record<string, string|undefined>,
|
|
212
|
-
*
|
|
233
|
+
* section?: string,
|
|
234
|
+
* envKey?: string,
|
|
235
|
+
* }} [opts] — `section` defaults to 'baselines', `envKey` to 'SO_BASELINE_PATH'
|
|
213
236
|
* @returns {{ path: string|null, name: string|null, source: 'match'|null }}
|
|
214
237
|
*/
|
|
215
238
|
export function resolveNamedBaseline({
|
|
216
239
|
cwd = process.cwd(),
|
|
217
240
|
ownerConfig,
|
|
218
241
|
env = process.env,
|
|
242
|
+
section = 'baselines',
|
|
243
|
+
envKey = 'SO_BASELINE_PATH',
|
|
219
244
|
} = {}) {
|
|
220
|
-
// Yield to the higher-precedence env tier the caller owns
|
|
221
|
-
const envVal = env?.
|
|
245
|
+
// Yield to the higher-precedence env tier the caller owns.
|
|
246
|
+
const envVal = env?.[envKey];
|
|
222
247
|
if (typeof envVal === 'string' && envVal.trim() !== '') {
|
|
223
248
|
return { path: null, name: null, source: null };
|
|
224
249
|
}
|
|
225
250
|
|
|
226
|
-
const
|
|
227
|
-
const matched =
|
|
251
|
+
const entries = parseBaselines(ownerConfig, section);
|
|
252
|
+
const matched = entries.length > 0 ? matchBaselineForPath(cwd, entries, section) : null;
|
|
228
253
|
if (matched !== null) {
|
|
229
254
|
return { path: matched.path, name: matched.name, source: 'match' };
|
|
230
255
|
}
|
|
@@ -47,11 +47,14 @@
|
|
|
47
47
|
* resolveCanonicalSuffixes({ ownerConfig, env })
|
|
48
48
|
* findRepoRoot(cwd, { existsSync, realpathSync })
|
|
49
49
|
* resolveNamedVault({ vaultName, cwd, ownerConfig, env, gitRemote, existsSync, realpathSync })
|
|
50
|
+
* normalizeRemote(url)
|
|
51
|
+
* checkCanonicalVault({ vaultDir, ownerConfig, env, readOriginUrl })
|
|
50
52
|
*/
|
|
51
53
|
|
|
52
54
|
import { join, dirname } from 'node:path';
|
|
53
55
|
import { existsSync as nodeExistsSync, realpathSync as nodeRealpathSync } from 'node:fs';
|
|
54
|
-
import {
|
|
56
|
+
import { spawnSync } from 'node:child_process';
|
|
57
|
+
import { resolvePreferredRemote, isQueryFailure, redactUrlCredentials } from './vcs-repo-spec.mjs';
|
|
55
58
|
|
|
56
59
|
// ---------------------------------------------------------------------------
|
|
57
60
|
// Internal helpers
|
|
@@ -255,6 +258,73 @@ export function resolveCanonicalSuffixes({ ownerConfig, env = process.env } = {}
|
|
|
255
258
|
return canonicalSuffixesFromVaults(vaults, env?.VAULT_MIRROR_CANONICAL_SUFFIX);
|
|
256
259
|
}
|
|
257
260
|
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// checkCanonicalVault — the canonical-vault write guard (#1450)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Default origin reader: `git -C <vaultDir> remote get-url origin`. Returns
|
|
267
|
+
* `''` on a spawn error or a non-zero exit, so every failure reads as "no
|
|
268
|
+
* origin" and the guard stays fail-closed.
|
|
269
|
+
* @param {string} vaultDir
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function _defaultReadOriginUrl(vaultDir) {
|
|
273
|
+
const res = spawnSync('git', ['-C', vaultDir, 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
274
|
+
if (res.error || res.status !== 0) return '';
|
|
275
|
+
return String(res.stdout ?? '').trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Decide whether `vaultDir` is a canonical Meta-Vault, i.e. whether its git
|
|
280
|
+
* `origin` ends in one of {@link resolveCanonicalSuffixes}. Shared by every
|
|
281
|
+
* writer into the vault so none of them can write into a foreign vault that
|
|
282
|
+
* `vault-dir` happened to resolve to.
|
|
283
|
+
*
|
|
284
|
+
* Fail-closed: a missing origin, a failing or throwing reader, and a suffix
|
|
285
|
+
* resolution that throws all return `ok: false`. The only bypass is the
|
|
286
|
+
* existing `VAULT_MIRROR_SKIP_CANONICAL_CHECK === '1'` switch, read from the
|
|
287
|
+
* `env` passed in (never from the ambient process.env when `env` is given).
|
|
288
|
+
* It is evaluated before any suffix resolution, so a skipped check neither
|
|
289
|
+
* parses `ownerConfig.vaults` nor prints its WARN lines, and returns
|
|
290
|
+
* `expected: []`.
|
|
291
|
+
*
|
|
292
|
+
* @param {{
|
|
293
|
+
* vaultDir: string,
|
|
294
|
+
* ownerConfig?: object,
|
|
295
|
+
* env?: Record<string, string|undefined>,
|
|
296
|
+
* readOriginUrl?: (vaultDir: string) => string,
|
|
297
|
+
* }} opts
|
|
298
|
+
* @returns {{ ok: boolean, reason?: 'skipped-by-env'|'no-git-origin'|'not-canonical', got?: string, expected: string[] }}
|
|
299
|
+
*/
|
|
300
|
+
export function checkCanonicalVault({ vaultDir, ownerConfig, env = process.env, readOriginUrl = _defaultReadOriginUrl } = {}) {
|
|
301
|
+
if (env?.VAULT_MIRROR_SKIP_CANONICAL_CHECK === '1') {
|
|
302
|
+
// Empty on purpose: suffix resolution is exactly what the switch skips, and
|
|
303
|
+
// callers read `expected` only on `ok: false`.
|
|
304
|
+
return { ok: true, reason: 'skipped-by-env', expected: [] };
|
|
305
|
+
}
|
|
306
|
+
let expected;
|
|
307
|
+
try {
|
|
308
|
+
expected = resolveCanonicalSuffixes({ ownerConfig, env });
|
|
309
|
+
} catch {
|
|
310
|
+
return { ok: false, reason: 'not-canonical', expected: [] };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let url;
|
|
314
|
+
try {
|
|
315
|
+
url = String(readOriginUrl(vaultDir) ?? '').trim();
|
|
316
|
+
} catch {
|
|
317
|
+
url = '';
|
|
318
|
+
}
|
|
319
|
+
if (!url) return { ok: false, reason: 'no-git-origin', expected };
|
|
320
|
+
|
|
321
|
+
const normalized = normalizeRemote(url);
|
|
322
|
+
if (!expected.some((s) => normalized.endsWith(s))) {
|
|
323
|
+
return { ok: false, reason: 'not-canonical', got: url, expected };
|
|
324
|
+
}
|
|
325
|
+
return { ok: true, expected };
|
|
326
|
+
}
|
|
327
|
+
|
|
258
328
|
// ---------------------------------------------------------------------------
|
|
259
329
|
// findRepoRoot — injectable IO
|
|
260
330
|
// ---------------------------------------------------------------------------
|
|
@@ -414,7 +484,7 @@ export function resolveNamedVault({
|
|
|
414
484
|
|
|
415
485
|
// Guard: if this repo IS one of the vaults, skip (don't self-mirror)
|
|
416
486
|
const canonicalSuffixes = canonicalSuffixesFromVaults(vaults, env?.VAULT_MIRROR_CANONICAL_SUFFIX);
|
|
417
|
-
const normalized =
|
|
487
|
+
const normalized = normalizeRemote(remoteUrl);
|
|
418
488
|
if (canonicalSuffixes.some((s) => normalized.endsWith(s))) {
|
|
419
489
|
// This is the vault itself — fall through to fallback
|
|
420
490
|
} else {
|
|
@@ -460,11 +530,14 @@ function _resolveEnvSuffix(env) {
|
|
|
460
530
|
}
|
|
461
531
|
|
|
462
532
|
/**
|
|
463
|
-
* Normalize a git remote URL to host/path tail
|
|
533
|
+
* Normalize a git remote URL to a host/path tail for canonical-suffix matching.
|
|
534
|
+
* Strips `.git`, the `git@host:` / scheme prefixes, and trailing slashes —
|
|
535
|
+
* byte-identical to vault-mirror.mjs `_normalizeRemote`, which predates this
|
|
536
|
+
* export and can import it from here.
|
|
464
537
|
* @param {string} url
|
|
465
538
|
* @returns {string}
|
|
466
539
|
*/
|
|
467
|
-
function
|
|
540
|
+
export function normalizeRemote(url) {
|
|
468
541
|
return String(url ?? '')
|
|
469
542
|
.trim()
|
|
470
543
|
.replace(/\.git$/, '')
|
|
@@ -473,6 +546,32 @@ function _normalizeRemoteUrl(url) {
|
|
|
473
546
|
.replace(/\/+$/, '');
|
|
474
547
|
}
|
|
475
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Render a git origin URL for a stderr line (#1479). The vault writers print
|
|
551
|
+
* the origin {@link checkCanonicalVault} rejected, and that stderr lands in CI
|
|
552
|
+
* logs and session transcripts — so a LOCAL origin must not carry its path
|
|
553
|
+
* (the operator's home dir, `.claude/rules/security.md` § Owner-Privacy).
|
|
554
|
+
*
|
|
555
|
+
* - `file:` URL, or a bare/relative path (git's rule: no `://` and no `:`
|
|
556
|
+
* before the first `/`, a Windows drive letter counting as a path)
|
|
557
|
+
* → `file://<local path>`.
|
|
558
|
+
* - Every other form (`https://`, `ssh://`, scp-like `git@host:path`) keeps its
|
|
559
|
+
* host/path and goes through {@link redactUrlCredentials}, so an
|
|
560
|
+
* `https://user:token@host/…` origin cannot leak its credential either.
|
|
561
|
+
*
|
|
562
|
+
* Returns `''` for an empty/absent origin; callers supply their own wording.
|
|
563
|
+
* @param {string|undefined|null} url
|
|
564
|
+
* @returns {string}
|
|
565
|
+
*/
|
|
566
|
+
export function describeOriginForLog(url) {
|
|
567
|
+
const s = String(url ?? '').trim();
|
|
568
|
+
if (!s) return '';
|
|
569
|
+
if (/^file:/i.test(s)) return 'file://<local path>';
|
|
570
|
+
if (s.includes('://')) return redactUrlCredentials(s);
|
|
571
|
+
const scpLike = /^[^/\\]*:/.test(s) && !/^[a-z]:[\\/]/i.test(s);
|
|
572
|
+
return scpLike ? redactUrlCredentials(s) : 'file://<local path>';
|
|
573
|
+
}
|
|
574
|
+
|
|
476
575
|
/**
|
|
477
576
|
* Derive an org/repo slug from a git remote URL.
|
|
478
577
|
* e.g. 'git@github.com:my-org/my-repo.git' → 'my-org/my-repo'
|
|
@@ -480,7 +579,7 @@ function _normalizeRemoteUrl(url) {
|
|
|
480
579
|
* @returns {string}
|
|
481
580
|
*/
|
|
482
581
|
function _deriveSlugFromRemote(url) {
|
|
483
|
-
const normalized =
|
|
582
|
+
const normalized = normalizeRemote(url);
|
|
484
583
|
// normalized is now 'host/path/to/repo' — take the last two segments
|
|
485
584
|
const parts = normalized.split('/').filter(Boolean);
|
|
486
585
|
if (parts.length >= 2) {
|