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.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
* ⇒ a source exists but could not be read; `truncated: true` ⇒
|
|
192
|
-
* out before the walk finished. In BOTH cases the caller must
|
|
193
|
-
* `undeterminable`, never clean — a walk that did not finish has not
|
|
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)
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
10
|
+
* Pure module — imports only node:crypto and the pure learnings/schema.mjs
|
|
11
|
+
* (type registry, repo-relative path predicate). No I/O, no quota enforcement
|
|
11
12
|
* (see store.mjs for those responsibilities).
|
|
12
13
|
*
|
|
13
14
|
* Canonical schema (schema_version: 1) — ALL required fields:
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
import { randomUUID } from 'node:crypto';
|
|
33
|
-
|
|
34
|
+
|
|
35
|
+
import { isRepoRelativePathEntry, LEARNING_TYPE_REGISTRY } from '../learnings/schema.mjs';
|
|
34
36
|
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
36
38
|
// Constants
|
|
@@ -251,24 +253,28 @@ export function validateProposalRecord(record) {
|
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
// file_paths (optional, but if present must be a non-empty array of
|
|
254
|
-
//
|
|
256
|
+
// repo-relative, newline-free, glob-metacharacter-free strings) — issue #900 C,
|
|
255
257
|
// Q3-MED fix pass: a glob metacharacter (* ? [ ] { }) surviving this schema
|
|
256
258
|
// gate would later reach emitter.mjs::globsFromFilePaths verbatim for a
|
|
257
259
|
// top-level (dirname==='.') entry, effectively producing an always-on rule
|
|
258
260
|
// glob (e.g. `file_paths: ['**']`). `createProposalRecord` never sets an
|
|
259
261
|
// empty array (it omits the key), so a present-but-empty `[]` is itself a
|
|
260
262
|
// signal of a malformed/tampered record — reject it explicitly.
|
|
263
|
+
// Repo-relative is the SAME predicate validateLearning applies at write time
|
|
264
|
+
// (isRepoRelativePathEntry): a record read back from proposals.jsonl is not
|
|
265
|
+
// re-validated before writeApproved → appendLearning, so an absolute, home-
|
|
266
|
+
// relative or URL entry must be refused here, not silently at promotion.
|
|
261
267
|
if ('file_paths' in record) {
|
|
262
268
|
const fp = record.file_paths;
|
|
263
269
|
const isValidArray =
|
|
264
270
|
Array.isArray(fp) &&
|
|
265
271
|
fp.length > 0 &&
|
|
266
272
|
fp.every(
|
|
267
|
-
(p) =>
|
|
273
|
+
(p) => isRepoRelativePathEntry(p) && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(p),
|
|
268
274
|
);
|
|
269
275
|
if (!isValidArray) {
|
|
270
276
|
errors.push(
|
|
271
|
-
'file_paths must be a non-empty array of
|
|
277
|
+
'file_paths must be a non-empty array of repo-relative, newline-free, glob-metacharacter-free strings when present',
|
|
272
278
|
);
|
|
273
279
|
}
|
|
274
280
|
}
|
|
@@ -29,11 +29,21 @@
|
|
|
29
29
|
* also YIELDS to tier 1: when SO_BASELINE_PATH is set (non-blank), resolveNamedBaseline
|
|
30
30
|
* returns the null-fallback ({source:null}) so the caller's env tier wins.
|
|
31
31
|
*
|
|
32
|
+
* ── Other sections (agents/vault#319) ────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* The same directory-prefix match serves any owner.yaml list of the shape
|
|
35
|
+
* `{ path, match: { path-prefix } }`. `section` picks the list and `envKey` the
|
|
36
|
+
* env var this tier yields to. The live second consumer is `vault-dirs:`
|
|
37
|
+
* (section 'vault-dirs', envKey 'SO_VAULT_DIR'), driven by
|
|
38
|
+
* `resolveVaultDir` in scripts/lib/config/host-paths.mjs. `name` is required
|
|
39
|
+
* only for `baselines:` (the #819 contract); elsewhere it is optional and
|
|
40
|
+
* defaults to `path`, which is what the ambiguity WARN then names.
|
|
41
|
+
*
|
|
32
42
|
* ── Exports ──────────────────────────────────────────────────────────────────
|
|
33
43
|
*
|
|
34
|
-
* parseBaselines(ownerConfig)
|
|
35
|
-
* matchBaselineForPath(absPath, baselines)
|
|
36
|
-
* resolveNamedBaseline({ cwd, ownerConfig, env })
|
|
44
|
+
* parseBaselines(ownerConfig, section?)
|
|
45
|
+
* matchBaselineForPath(absPath, baselines, section?)
|
|
46
|
+
* resolveNamedBaseline({ cwd, ownerConfig, env, section?, envKey? })
|
|
37
47
|
*/
|
|
38
48
|
|
|
39
49
|
import { normalize, sep } from 'node:path';
|
|
@@ -43,6 +53,9 @@ import { expandTilde } from './common.mjs';
|
|
|
43
53
|
// Internal helpers
|
|
44
54
|
// ---------------------------------------------------------------------------
|
|
45
55
|
|
|
56
|
+
/** Sections whose entries MUST carry a `name` (the #819 baselines contract). */
|
|
57
|
+
const NAME_REQUIRED_SECTIONS = new Set(['baselines']);
|
|
58
|
+
|
|
46
59
|
/** @param {unknown} v */
|
|
47
60
|
function isPlainObject(v) {
|
|
48
61
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -70,18 +83,23 @@ function _normalizePath(p) {
|
|
|
70
83
|
// ---------------------------------------------------------------------------
|
|
71
84
|
|
|
72
85
|
/**
|
|
73
|
-
* Extract and validate
|
|
86
|
+
* Extract and validate a directory-prefix list (default `baselines:`) from a raw
|
|
87
|
+
* ownerConfig object.
|
|
74
88
|
*
|
|
75
89
|
* Drop-and-WARN on malformed entries; never throw. Returns [] when the section
|
|
76
|
-
* is absent, null, or empty — the backward-compat no-op path.
|
|
90
|
+
* is absent, null, or empty — the backward-compat no-op path. Every WARN names
|
|
91
|
+
* the section it came from.
|
|
77
92
|
*
|
|
78
|
-
* Each valid entry: { name: string, path: string, match: { 'path-prefix': string } }
|
|
93
|
+
* Each valid entry: { name: string, path: string, match: { 'path-prefix': string } }.
|
|
94
|
+
* `name` is required for `baselines`; for other sections an absent name falls
|
|
95
|
+
* back to `path` (a present-but-blank or non-string name is still dropped).
|
|
79
96
|
*
|
|
80
97
|
* @param {object|undefined} ownerConfig — raw parsed owner.yaml (NOT merged with defaults)
|
|
98
|
+
* @param {string} [section='baselines'] — owner.yaml list to read (e.g. 'vault-dirs')
|
|
81
99
|
* @returns {Array<{name:string, path:string, match:{'path-prefix':string}}>}
|
|
82
100
|
*/
|
|
83
|
-
export function parseBaselines(ownerConfig) {
|
|
84
|
-
const raw = ownerConfig?.
|
|
101
|
+
export function parseBaselines(ownerConfig, section = 'baselines') {
|
|
102
|
+
const raw = ownerConfig?.[section];
|
|
85
103
|
|
|
86
104
|
// Absent or explicit null/empty → no-op
|
|
87
105
|
if (raw === undefined || raw === null) return [];
|
|
@@ -89,54 +107,56 @@ export function parseBaselines(ownerConfig) {
|
|
|
89
107
|
|
|
90
108
|
if (!Array.isArray(raw)) {
|
|
91
109
|
process.stderr.write(
|
|
92
|
-
|
|
110
|
+
`WARN named-baseline-resolver: owner.yaml ${section}: must be an array; ignoring\n`,
|
|
93
111
|
);
|
|
94
112
|
return [];
|
|
95
113
|
}
|
|
96
114
|
|
|
115
|
+
const nameRequired = NAME_REQUIRED_SECTIONS.has(section);
|
|
97
116
|
const result = [];
|
|
98
117
|
for (let i = 0; i < raw.length; i++) {
|
|
99
118
|
const entry = raw[i];
|
|
100
119
|
if (!isPlainObject(entry)) {
|
|
101
120
|
process.stderr.write(
|
|
102
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
121
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}] is not an object; dropping\n`,
|
|
103
122
|
);
|
|
104
123
|
continue;
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
const { name, path: entryPath, match } = entry;
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
const nameAbsent = name === undefined || name === null;
|
|
129
|
+
if ((nameRequired || !nameAbsent) && (typeof name !== 'string' || name.trim() === '')) {
|
|
110
130
|
process.stderr.write(
|
|
111
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
131
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].name must be a non-empty string; dropping entry\n`,
|
|
112
132
|
);
|
|
113
133
|
continue;
|
|
114
134
|
}
|
|
115
135
|
if (typeof entryPath !== 'string' || entryPath.trim() === '') {
|
|
116
136
|
process.stderr.write(
|
|
117
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
137
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].path must be a non-empty string; dropping entry\n`,
|
|
118
138
|
);
|
|
119
139
|
continue;
|
|
120
140
|
}
|
|
121
141
|
|
|
122
|
-
// match is REQUIRED (unlike vaults) —
|
|
142
|
+
// match is REQUIRED (unlike vaults) — an entry with no path-prefix can
|
|
123
143
|
// never be selected, so it is dropped rather than kept.
|
|
124
144
|
if (!isPlainObject(match)) {
|
|
125
145
|
process.stderr.write(
|
|
126
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
146
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].match must be an object; dropping entry\n`,
|
|
127
147
|
);
|
|
128
148
|
continue;
|
|
129
149
|
}
|
|
130
150
|
const pathPrefix = match['path-prefix'];
|
|
131
151
|
if (typeof pathPrefix !== 'string' || pathPrefix.trim() === '') {
|
|
132
152
|
process.stderr.write(
|
|
133
|
-
`WARN named-baseline-resolver: owner.yaml
|
|
153
|
+
`WARN named-baseline-resolver: owner.yaml ${section}[${i}].match.path-prefix must be a non-empty string; dropping entry\n`,
|
|
134
154
|
);
|
|
135
155
|
continue;
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
result.push({
|
|
139
|
-
name: name.trim(),
|
|
159
|
+
name: nameAbsent ? entryPath.trim() : name.trim(),
|
|
140
160
|
path: entryPath.trim(),
|
|
141
161
|
match: { 'path-prefix': pathPrefix.trim() },
|
|
142
162
|
});
|
|
@@ -162,9 +182,10 @@ export function parseBaselines(ownerConfig) {
|
|
|
162
182
|
*
|
|
163
183
|
* @param {string} absPath — the working directory to classify
|
|
164
184
|
* @param {Array<{name:string, path:string, match:{'path-prefix':string}}>} baselines
|
|
185
|
+
* @param {string} [section='baselines'] — owner.yaml list name, used only in the WARN
|
|
165
186
|
* @returns {{name:string, path:string, match:{'path-prefix':string}}|null}
|
|
166
187
|
*/
|
|
167
|
-
export function matchBaselineForPath(absPath, baselines) {
|
|
188
|
+
export function matchBaselineForPath(absPath, baselines, section = 'baselines') {
|
|
168
189
|
if (!absPath || !Array.isArray(baselines) || baselines.length === 0) return null;
|
|
169
190
|
|
|
170
191
|
const target = _normalizePath(absPath);
|
|
@@ -181,7 +202,7 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
181
202
|
|
|
182
203
|
if (matched.length > 1) {
|
|
183
204
|
process.stderr.write(
|
|
184
|
-
`WARN named-baseline-resolver: multiple
|
|
205
|
+
`WARN named-baseline-resolver: multiple ${section} match path "${absPath}": ${matched
|
|
185
206
|
.map((b) => b.name)
|
|
186
207
|
.join(', ')}; using first match "${matched[0].name}"\n`,
|
|
187
208
|
);
|
|
@@ -195,13 +216,13 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
195
216
|
// ---------------------------------------------------------------------------
|
|
196
217
|
|
|
197
218
|
/**
|
|
198
|
-
* Resolve the
|
|
219
|
+
* Resolve the directory-prefix match tier (default `baselines:`) for a given cwd.
|
|
199
220
|
*
|
|
200
221
|
* Returns:
|
|
201
|
-
* { path, name, source:'match' } when
|
|
222
|
+
* { path, name, source:'match' } when an entry's directory-prefix matches cwd.
|
|
202
223
|
* { path:null, name:null, source:null } (the null-fallback) otherwise — including
|
|
203
|
-
* when
|
|
204
|
-
*
|
|
224
|
+
* when `env[envKey]` is set (this tier yields to the higher env tier the caller
|
|
225
|
+
* owns), when the section is not configured, or when nothing matches.
|
|
205
226
|
*
|
|
206
227
|
* Pure + synchronous — no git calls, no disk reads. Never throws.
|
|
207
228
|
*
|
|
@@ -209,22 +230,26 @@ export function matchBaselineForPath(absPath, baselines) {
|
|
|
209
230
|
* cwd?: string,
|
|
210
231
|
* ownerConfig?: object,
|
|
211
232
|
* env?: Record<string, string|undefined>,
|
|
212
|
-
*
|
|
233
|
+
* section?: string,
|
|
234
|
+
* envKey?: string,
|
|
235
|
+
* }} [opts] — `section` defaults to 'baselines', `envKey` to 'SO_BASELINE_PATH'
|
|
213
236
|
* @returns {{ path: string|null, name: string|null, source: 'match'|null }}
|
|
214
237
|
*/
|
|
215
238
|
export function resolveNamedBaseline({
|
|
216
239
|
cwd = process.cwd(),
|
|
217
240
|
ownerConfig,
|
|
218
241
|
env = process.env,
|
|
242
|
+
section = 'baselines',
|
|
243
|
+
envKey = 'SO_BASELINE_PATH',
|
|
219
244
|
} = {}) {
|
|
220
|
-
// Yield to the higher-precedence env tier the caller owns
|
|
221
|
-
const envVal = env?.
|
|
245
|
+
// Yield to the higher-precedence env tier the caller owns.
|
|
246
|
+
const envVal = env?.[envKey];
|
|
222
247
|
if (typeof envVal === 'string' && envVal.trim() !== '') {
|
|
223
248
|
return { path: null, name: null, source: null };
|
|
224
249
|
}
|
|
225
250
|
|
|
226
|
-
const
|
|
227
|
-
const matched =
|
|
251
|
+
const entries = parseBaselines(ownerConfig, section);
|
|
252
|
+
const matched = entries.length > 0 ? matchBaselineForPath(cwd, entries, section) : null;
|
|
228
253
|
if (matched !== null) {
|
|
229
254
|
return { path: matched.path, name: matched.name, source: 'match' };
|
|
230
255
|
}
|
|
@@ -47,11 +47,14 @@
|
|
|
47
47
|
* resolveCanonicalSuffixes({ ownerConfig, env })
|
|
48
48
|
* findRepoRoot(cwd, { existsSync, realpathSync })
|
|
49
49
|
* resolveNamedVault({ vaultName, cwd, ownerConfig, env, gitRemote, existsSync, realpathSync })
|
|
50
|
+
* normalizeRemote(url)
|
|
51
|
+
* checkCanonicalVault({ vaultDir, ownerConfig, env, readOriginUrl })
|
|
50
52
|
*/
|
|
51
53
|
|
|
52
54
|
import { join, dirname } from 'node:path';
|
|
53
55
|
import { existsSync as nodeExistsSync, realpathSync as nodeRealpathSync } from 'node:fs';
|
|
54
|
-
import {
|
|
56
|
+
import { spawnSync } from 'node:child_process';
|
|
57
|
+
import { resolvePreferredRemote, isQueryFailure, redactUrlCredentials } from './vcs-repo-spec.mjs';
|
|
55
58
|
|
|
56
59
|
// ---------------------------------------------------------------------------
|
|
57
60
|
// Internal helpers
|
|
@@ -255,6 +258,73 @@ export function resolveCanonicalSuffixes({ ownerConfig, env = process.env } = {}
|
|
|
255
258
|
return canonicalSuffixesFromVaults(vaults, env?.VAULT_MIRROR_CANONICAL_SUFFIX);
|
|
256
259
|
}
|
|
257
260
|
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// checkCanonicalVault — the canonical-vault write guard (#1450)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Default origin reader: `git -C <vaultDir> remote get-url origin`. Returns
|
|
267
|
+
* `''` on a spawn error or a non-zero exit, so every failure reads as "no
|
|
268
|
+
* origin" and the guard stays fail-closed.
|
|
269
|
+
* @param {string} vaultDir
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function _defaultReadOriginUrl(vaultDir) {
|
|
273
|
+
const res = spawnSync('git', ['-C', vaultDir, 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
274
|
+
if (res.error || res.status !== 0) return '';
|
|
275
|
+
return String(res.stdout ?? '').trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Decide whether `vaultDir` is a canonical Meta-Vault, i.e. whether its git
|
|
280
|
+
* `origin` ends in one of {@link resolveCanonicalSuffixes}. Shared by every
|
|
281
|
+
* writer into the vault so none of them can write into a foreign vault that
|
|
282
|
+
* `vault-dir` happened to resolve to.
|
|
283
|
+
*
|
|
284
|
+
* Fail-closed: a missing origin, a failing or throwing reader, and a suffix
|
|
285
|
+
* resolution that throws all return `ok: false`. The only bypass is the
|
|
286
|
+
* existing `VAULT_MIRROR_SKIP_CANONICAL_CHECK === '1'` switch, read from the
|
|
287
|
+
* `env` passed in (never from the ambient process.env when `env` is given).
|
|
288
|
+
* It is evaluated before any suffix resolution, so a skipped check neither
|
|
289
|
+
* parses `ownerConfig.vaults` nor prints its WARN lines, and returns
|
|
290
|
+
* `expected: []`.
|
|
291
|
+
*
|
|
292
|
+
* @param {{
|
|
293
|
+
* vaultDir: string,
|
|
294
|
+
* ownerConfig?: object,
|
|
295
|
+
* env?: Record<string, string|undefined>,
|
|
296
|
+
* readOriginUrl?: (vaultDir: string) => string,
|
|
297
|
+
* }} opts
|
|
298
|
+
* @returns {{ ok: boolean, reason?: 'skipped-by-env'|'no-git-origin'|'not-canonical', got?: string, expected: string[] }}
|
|
299
|
+
*/
|
|
300
|
+
export function checkCanonicalVault({ vaultDir, ownerConfig, env = process.env, readOriginUrl = _defaultReadOriginUrl } = {}) {
|
|
301
|
+
if (env?.VAULT_MIRROR_SKIP_CANONICAL_CHECK === '1') {
|
|
302
|
+
// Empty on purpose: suffix resolution is exactly what the switch skips, and
|
|
303
|
+
// callers read `expected` only on `ok: false`.
|
|
304
|
+
return { ok: true, reason: 'skipped-by-env', expected: [] };
|
|
305
|
+
}
|
|
306
|
+
let expected;
|
|
307
|
+
try {
|
|
308
|
+
expected = resolveCanonicalSuffixes({ ownerConfig, env });
|
|
309
|
+
} catch {
|
|
310
|
+
return { ok: false, reason: 'not-canonical', expected: [] };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let url;
|
|
314
|
+
try {
|
|
315
|
+
url = String(readOriginUrl(vaultDir) ?? '').trim();
|
|
316
|
+
} catch {
|
|
317
|
+
url = '';
|
|
318
|
+
}
|
|
319
|
+
if (!url) return { ok: false, reason: 'no-git-origin', expected };
|
|
320
|
+
|
|
321
|
+
const normalized = normalizeRemote(url);
|
|
322
|
+
if (!expected.some((s) => normalized.endsWith(s))) {
|
|
323
|
+
return { ok: false, reason: 'not-canonical', got: url, expected };
|
|
324
|
+
}
|
|
325
|
+
return { ok: true, expected };
|
|
326
|
+
}
|
|
327
|
+
|
|
258
328
|
// ---------------------------------------------------------------------------
|
|
259
329
|
// findRepoRoot — injectable IO
|
|
260
330
|
// ---------------------------------------------------------------------------
|
|
@@ -414,7 +484,7 @@ export function resolveNamedVault({
|
|
|
414
484
|
|
|
415
485
|
// Guard: if this repo IS one of the vaults, skip (don't self-mirror)
|
|
416
486
|
const canonicalSuffixes = canonicalSuffixesFromVaults(vaults, env?.VAULT_MIRROR_CANONICAL_SUFFIX);
|
|
417
|
-
const normalized =
|
|
487
|
+
const normalized = normalizeRemote(remoteUrl);
|
|
418
488
|
if (canonicalSuffixes.some((s) => normalized.endsWith(s))) {
|
|
419
489
|
// This is the vault itself — fall through to fallback
|
|
420
490
|
} else {
|
|
@@ -460,11 +530,14 @@ function _resolveEnvSuffix(env) {
|
|
|
460
530
|
}
|
|
461
531
|
|
|
462
532
|
/**
|
|
463
|
-
* Normalize a git remote URL to host/path tail
|
|
533
|
+
* Normalize a git remote URL to a host/path tail for canonical-suffix matching.
|
|
534
|
+
* Strips `.git`, the `git@host:` / scheme prefixes, and trailing slashes —
|
|
535
|
+
* byte-identical to vault-mirror.mjs `_normalizeRemote`, which predates this
|
|
536
|
+
* export and can import it from here.
|
|
464
537
|
* @param {string} url
|
|
465
538
|
* @returns {string}
|
|
466
539
|
*/
|
|
467
|
-
function
|
|
540
|
+
export function normalizeRemote(url) {
|
|
468
541
|
return String(url ?? '')
|
|
469
542
|
.trim()
|
|
470
543
|
.replace(/\.git$/, '')
|
|
@@ -473,6 +546,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 =
|
|
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
|
+
}
|