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.
Files changed (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. 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] — GitLab #386. When `true`, a
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 with `scope:
241
- * "src/components/landing/mobile-sticky-cta.tsx"` — a producer bug that
242
- * wrote a file path into the scope column, which `normalizeLearning` never
243
- * validates and therefore never rejects). A field that IS present keeps
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 with `scope:
300
- // "src/components/landing/mobile-sticky-cta.tsx"` — evidently a producer
301
- // bug that wrote a file path into the scope column). All three checks are
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} as the final
487
- * step. Timestamp FORMAT is deliberately NOT reformatted here (byte-exact
488
- * contract preserved); the read funnel + backfill canonicalize timestamps.
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
- const COERCIBLE_SCOPES = new Set(['vault-tools', 'deep-sessions', 'wave-executor', 'coordinator']);
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 last (files→file_paths, session_id/last_seen/
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
- return normalizeDialects(out, { reserializeTimestamps: false });
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 over-delivery
13
- * median for one profile silently absorbed the other's.
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 leaf module — imports only node:crypto. No I/O, no quota enforcement
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
- import { LEARNING_TYPE_REGISTRY } from '../learnings/schema.mjs';
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
- // non-empty, newline-free, glob-metacharacter-free strings) — issue #900 C,
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) => typeof p === 'string' && p.length > 0 && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(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 non-empty, newline-free, glob-metacharacter-free strings when present',
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 the `baselines:` list from a raw ownerConfig object.
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?.baselines;
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
- 'WARN named-baseline-resolver: owner.yaml baselines: must be an array; ignoring\n',
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 baselines[${i}] is not an object; dropping\n`,
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
- if (typeof name !== 'string' || name.trim() === '') {
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 baselines[${i}].name must be a non-empty string; dropping entry\n`,
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 baselines[${i}].path must be a non-empty string; dropping entry\n`,
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) — a baseline with no path-prefix can
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 baselines[${i}].match must be an object; dropping entry\n`,
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 baselines[${i}].match.path-prefix must be a non-empty string; dropping entry\n`,
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 baselines match path "${absPath}": ${matched
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 `baselines:` match tier for a given cwd.
219
+ * Resolve the directory-prefix match tier (default `baselines:`) for a given cwd.
199
220
  *
200
221
  * Returns:
201
- * { path, name, source:'match' } when a baseline directory-prefix matches cwd.
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 SO_BASELINE_PATH env is set (this tier yields to the higher env tier the
204
- * caller owns), when no `baselines:` are configured, or when nothing matches.
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
- * }} [opts]
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 (SO_BASELINE_PATH).
221
- const envVal = env?.SO_BASELINE_PATH;
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 baselines = parseBaselines(ownerConfig);
227
- const matched = baselines.length > 0 ? matchBaselineForPath(cwd, baselines) : null;
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 { resolvePreferredRemote, isQueryFailure } from './vcs-repo-spec.mjs';
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 = _normalizeRemoteUrl(remoteUrl);
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 (mirrors vault-mirror.mjs _normalizeRemote).
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 _normalizeRemoteUrl(url) {
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 = _normalizeRemoteUrl(url);
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) {