session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -66,7 +66,7 @@ import os from 'node:os';
66
66
  import path from 'node:path';
67
67
 
68
68
  import { enumerateCandidates } from './dispatcher/enumerate.mjs';
69
- import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
69
+ import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS, FUTURE_HEARTBEAT_TOLERANCE_MS } from './session-lock.mjs';
70
70
  import { emitEvent } from './events.mjs';
71
71
  import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
72
72
 
@@ -109,21 +109,42 @@ function lockPathFor(repoRoot) {
109
109
  }
110
110
 
111
111
  /**
112
- * Fractional hours since a lock's last heartbeat (the liveness basis). Falls
113
- * back to started_at when last_heartbeat is absent (v1 lock). Returns null when
114
- * neither parses.
112
+ * Fractional hours since a lock's last heartbeat — the figure the reaper
113
+ * REPORTS (candidate list `ageHours`, `orchestrator.session.lock.reaped`
114
+ * `age_hours`). Display only: the reap decision is `isLockLive()`.
115
+ *
116
+ * Reads `last_heartbeat` only, like `isLockLive()` since the #595 sunset
117
+ * (2026-10-02). A lock without one has no heartbeat age, so this returns
118
+ * `null` (the CLI renders "unknown age") instead of passing off the age since
119
+ * `started_at` as heartbeat staleness.
115
120
  *
116
121
  * @param {object} lock
117
122
  * @param {number} nowMs
118
- * @returns {number|null}
123
+ * @returns {number|null} null when `last_heartbeat` is absent or unparseable.
119
124
  */
120
125
  function ageHoursOf(lock, nowMs) {
121
- const hb = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
122
- ? lock.last_heartbeat
123
- : lock.started_at;
124
- const ms = Date.parse(hb);
126
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return null;
127
+ const ms = Date.parse(lock.last_heartbeat);
125
128
  if (Number.isNaN(ms)) return null;
126
- return Number(((nowMs - ms) / (3600 * 1000)).toFixed(2));
129
+ const hours = futureSafeAgeHours(ms, nowMs);
130
+ return hours === null ? null : Number(hours.toFixed(2));
131
+ }
132
+
133
+ /**
134
+ * Hours from `ms` to `nowMs`, or `null` when `ms` lies further in the future
135
+ * than {@link FUTURE_HEARTBEAT_TOLERANCE_MS} (#1505.2). Such a stamp measures
136
+ * no age, and a negative one is worse than none: it reads as "younger than
137
+ * every TTL" and was emitted as a negative `age_hours`. `null` — not 0 — so an
138
+ * unmeasurable age never looks like a fresh one. Within the tolerance (clock
139
+ * skew) the age clamps to 0.
140
+ *
141
+ * @param {number} ms
142
+ * @param {number} nowMs
143
+ * @returns {number|null}
144
+ */
145
+ function futureSafeAgeHours(ms, nowMs) {
146
+ if (ms > nowMs + FUTURE_HEARTBEAT_TOLERANCE_MS) return null;
147
+ return Math.max(0, nowMs - ms) / (3600 * 1000);
127
148
  }
128
149
 
129
150
  /**
@@ -223,12 +244,15 @@ function currentSessionPathFor(repoRoot) {
223
244
  function currentSessionAgeHours(parsed, filePath, nowMs, fsDep) {
224
245
  const ts = typeof parsed?.timestamp === 'string' ? parsed.timestamp : null;
225
246
  const ms = ts ? Date.parse(ts) : NaN;
226
- if (!Number.isNaN(ms)) {
227
- return (nowMs - ms) / (3600 * 1000);
228
- }
247
+ // #1505.2: a `timestamp` beyond the future tolerance measures nothing — it
248
+ // fell through as a negative age, always under the TTL, and kept the file
249
+ // 'fresh' forever. It is treated like an absent one: the mtime decides.
250
+ const fromTimestamp = Number.isNaN(ms) ? null : futureSafeAgeHours(ms, nowMs);
251
+ if (fromTimestamp !== null) return fromTimestamp;
229
252
  try {
230
253
  const info = fsDep.statSync(filePath);
231
- return (nowMs - info.mtimeMs) / (3600 * 1000);
254
+ // A future mtime too → null → 'fresh': kept until the clock passes it.
255
+ return futureSafeAgeHours(info.mtimeMs, nowMs);
232
256
  } catch {
233
257
  return null;
234
258
  }
@@ -132,6 +132,14 @@ const MS_PER_DAY = 86_400_000;
132
132
  /** The event name whose ABSENCE is the `evolve` signal. */
133
133
  const EVOLVE_EVENT = 'orchestrator.evolve.completed';
134
134
 
135
+ /**
136
+ * Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
137
+ * `null` — the one ledger question here ("was {@link EVOLVE_EVENT} EVER
138
+ * recorded?") is about every line ever written, so no day count bounds it; a
139
+ * pruned history turns the answer `undeterminable`, never "never" (#1489).
140
+ */
141
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
142
+
135
143
  /** Pending-proposal sidecars, repo-relative (owned by auto-dream / auto-dialectic). */
136
144
  const PENDING_SIDECARS = ['.orchestrator/pending-dream.md', '.orchestrator/dialectic-pending.md'];
137
145
 
@@ -187,11 +195,13 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
187
195
  * /evolve, which is the HR-101 failure mode this module exists to avoid.
188
196
  *
189
197
  * @param {string} repoRoot
190
- * @returns {{ok: boolean, lastAt: string|null, truncated: boolean}} `ok: false`
191
- * ⇒ a source exists but could not be read; `truncated: true` ⇒ the budget ran
192
- * out before the walk finished. In BOTH cases the caller must record
193
- * `undeterminable`, never clean — a walk that did not finish has not proven
194
- * "never".
198
+ * @returns {{ok: boolean, lastAt: string|null, truncated: boolean, historyCut: boolean}}
199
+ * `ok: false` ⇒ a source exists but could not be read; `truncated: true` ⇒
200
+ * the budget ran out before the walk finished. In BOTH cases the caller must
201
+ * record `undeterminable`, never clean — a walk that did not finish has not
202
+ * proven "never". `historyCut: true` ⇒ the walk finished but the history it
203
+ * read begins after an archive that is gone (#1489: pruned by
204
+ * `events-rotation.max-backups`), so a miss has not proven "never" either.
195
205
  */
196
206
  function readLastEvolveRun(repoRoot) {
197
207
  const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
@@ -209,8 +219,10 @@ function readLastEvolveRun(repoRoot) {
209
219
  return true;
210
220
  },
211
221
  });
212
- if (scan.unreadable.length > 0) return { ok: false, lastAt: null, truncated: false };
213
- return { ok: true, lastAt, truncated: scan.truncated };
222
+ if (scan.unreadable.length > 0) {
223
+ return { ok: false, lastAt: null, truncated: false, historyCut: false };
224
+ }
225
+ return { ok: true, lastAt, truncated: scan.truncated, historyCut: scan.gaps.length > 0 };
214
226
  }
215
227
 
216
228
  /**
@@ -301,7 +313,10 @@ export async function computeMaintenanceDue(opts = {}) {
301
313
  if (!evolve.ok || evolve.truncated) {
302
314
  undeterminable.push('evolve');
303
315
  } else if (evolve.lastAt === null && nudge.activeLearnings >= MAINTENANCE_MIN_LEARNINGS) {
304
- markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
316
+ // #1489: judged only where it would flip the verdict, so a cut history
317
+ // adds no row to a repo the floor already keeps silent (HR-101).
318
+ if (evolve.historyCut) undeterminable.push('evolve');
319
+ else markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
305
320
  }
306
321
 
307
322
  // --- reconcile (S3) ----------------------------------------------------
@@ -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,75 @@ 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
+ * - Login rule, both remote forms: a userinfo other than the forge-shared `git`
559
+ * is the operator's account (or a password-shaped `user:pass`) → `***@`, the
560
+ * same marker {@link redactUrlCredentials} uses; it never takes part in the
561
+ * suffix match, so dropping it costs the line no diagnostic value.
562
+ * - A URL form (`ssh://`, `https://`, … #1490 item 5) applies the login rule,
563
+ * keeps its host/port, and keeps its path UNLESS the path has the absolute
564
+ * home-dir shape (`/home/<u>…`, `/Users/<u>…`, `/root…`, `/~…`) →
565
+ * `/<remote path>`. In URL form every path starts with `/`, so a forge's
566
+ * `/org/repo` and a plain `/srv/vault` cannot be told from a filesystem path;
567
+ * only the home-dir shape is the owner leak, and the forge shape is what the
568
+ * suffix comparison is about. The result still goes through
569
+ * {@link redactUrlCredentials}, so a bare `https://git@` (a token slot there)
570
+ * is redacted as before. A `://` with no scheme before it (`/srv/a://b`,
571
+ * `host:/p://q`) is no URL form: the path and scp rules read it (#1496).
572
+ * - A scp-like `[user@]host:path` (#1487 item 10) applies the login rule and
573
+ * keeps the host and a RELATIVE path — the repo shape the canonical-suffix
574
+ * comparison is about. Here a path starting with `/` or `~` is unambiguous: a
575
+ * filesystem path on that host (typically a home dir) → `<remote path>`. A
576
+ * bracketed IPv6 host (`[::1]:path`, `user@[::1]:path`, `[user@::1]:path`)
577
+ * follows the same two rules.
578
+ *
579
+ * Returns `''` for an empty/absent origin; callers supply their own wording.
580
+ * @param {string|undefined|null} url
581
+ * @returns {string}
582
+ */
583
+ export function describeOriginForLog(url) {
584
+ const s = String(url ?? '').trim();
585
+ if (!s) return '';
586
+ if (/^file:/i.test(s)) return 'file://<local path>';
587
+ const login = (u) => (u === undefined ? '' : u === 'git' ? 'git@' : '***@');
588
+ // A `://` with no scheme in front of it is no URL — git's is_url() reads
589
+ // such a string by the scp/local rules below, and so does this (#1496: it
590
+ // used to be printed verbatim, home dir and all).
591
+ const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(.*)$/is.exec(s);
592
+ if (m) {
593
+ const [, scheme, authority, urlPath] = m;
594
+ // userinfo runs to the LAST '@' of the authority (a password may hold one).
595
+ const at = authority.lastIndexOf('@');
596
+ const user = at === -1 ? undefined : authority.slice(0, at);
597
+ const homeShaped = /^\/(?:~|(?:home|Users)\/[^/]+(?:\/|$)|root(?:\/|$))/.test(urlPath);
598
+ return redactUrlCredentials(
599
+ `${scheme}${login(user)}${authority.slice(at + 1)}${homeShaped ? '/<remote path>' : urlPath}`,
600
+ );
601
+ }
602
+ const scpLike = /^[^/\\]*:/.test(s) && !/^[a-z]:[\\/]/i.test(s);
603
+ if (!scpLike) return 'file://<local path>';
604
+ // Split host from path the way git's connect.c host_end() does: a host in
605
+ // brackets — leading, or right after `user@` — runs to its `]`, so the ':'
606
+ // inside an IPv6 literal (`[::1]`, `[alice@::1]`) is never the separator.
607
+ // Otherwise the userinfo runs to the last '@' and the host to the next ':';
608
+ // `scpLike` guarantees a ':' with no '/' or '\' before it, so that second
609
+ // pattern always matches.
610
+ const [, user, host, path] =
611
+ /^(?:([^[]*)@)?(\[[^\]]*\]):(.*)$/s.exec(s) ?? /^(?:(.*)@)?([^@:]*):(.*)$/s.exec(s);
612
+ // A login inside the brackets is the same account as one before them.
613
+ const shownHost = host.replace(/^\[([^\]]*)@/, (_, u) => `[${login(u)}`);
614
+ const where = /^[/~]/.test(path) ? '<remote path>' : path;
615
+ return redactUrlCredentials(`${login(user)}${shownHost}:${where}`);
616
+ }
617
+
476
618
  /**
477
619
  * Derive an org/repo slug from a git remote URL.
478
620
  * e.g. 'git@github.com:my-org/my-repo.git' → 'my-org/my-repo'
@@ -480,7 +622,7 @@ function _normalizeRemoteUrl(url) {
480
622
  * @returns {string}
481
623
  */
482
624
  function _deriveSlugFromRemote(url) {
483
- const normalized = _normalizeRemoteUrl(url);
625
+ const normalized = normalizeRemote(url);
484
626
  // normalized is now 'host/path/to/repo' — take the last two segments
485
627
  const parts = normalized.split('/').filter(Boolean);
486
628
  if (parts.length >= 2) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * orphan-reaper/defaults.mjs — the reaper's parameter table and its repo-path join.
3
+ *
4
+ * Imports nothing but `node:path` on purpose: the trigger hooks reach it through
5
+ * `scan-throttle.mjs` on a hot path whose import closure is measured (#1432), and
6
+ * every other reaper module shares these numbers instead of re-spelling them.
7
+ */
8
+
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * Stufe-1 parameters. Every number carries its provenance from the PRD
13
+ * parameter table (`docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md`
14
+ * § 4) — NOT calibrated, so the provenance is what makes a later re-measurement
15
+ * possible ("keine Zahl ohne ihre Population").
16
+ */
17
+ export const REAPER_DEFAULTS = Object.freeze({
18
+ /** `reaper.min-age-seconds`: DevWatchdogs' hard limit for `tsgo`; the
19
+ * 2026-09-20 orphans were 7-17 min old, comfortably above it. */
20
+ minAgeSeconds: 300,
21
+ /** `reaper.min-scan-interval-seconds`: DevWatchdogs' normal scan cadence —
22
+ * keeps a `PostToolBatch` storm from taxing every tool call. */
23
+ minScanIntervalSeconds: 30,
24
+ /** `reaper.kill-grace-ms`: `DEFAULT_KILL_GRACE_MS` from `process-group.mjs` —
25
+ * a repo convention, not a new number. A literal rather than an import so this
26
+ * module stays out of `process-group.mjs`'s closure. */
27
+ killGraceMs: 10_000,
28
+ /** `reaper.verify-wait-ms`: without it the 2026-09-20 hand-run cleanup
29
+ * reported "still alive" for processes that were already gone. */
30
+ verifyWaitMs: 500,
31
+ /** `reaper.max-hook-latency-ms`: ceiling a scan may delay a hook by. */
32
+ maxHookLatencyMs: 50,
33
+ /** `reaper.false-alarm-window`: last N audit decisions — a ROLLING window
34
+ * rather than calendar time, so a quiet host still has a population (HR-101). */
35
+ falseAlarmWindow: 50,
36
+ });
37
+
38
+ /** @param {string} repoRoot @param {string} relpath @returns {string} */
39
+ export function underRepo(repoRoot, relpath) {
40
+ return path.join(repoRoot, ...relpath.split('/'));
41
+ }