session-orchestrator 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* enumerate.mjs — Candidate-repo enumeration + free/busy resolution.
|
|
3
3
|
*
|
|
4
4
|
* Epic #673 Phase 2 (issue #676, PRD §2 P2.1+P2.2, §4). Enumerates candidate
|
|
5
|
-
* repos
|
|
6
|
-
*
|
|
5
|
+
* repos below a confinement root (recursive walk, fixed at a depth of
|
|
6
|
+
* {@link DEFAULT_MAX_DEPTH} — see that constant for the measurement backing
|
|
7
|
+
* the value) and resolves each as free or busy via its per-repo
|
|
8
|
+
* `session.lock` v2 lease (heartbeat-based liveness).
|
|
7
9
|
*
|
|
8
10
|
* Source of truth for free/busy: the same lease semantics as
|
|
9
11
|
* `scripts/lib/vault-status/board-writer.mjs` collectRows —
|
|
@@ -15,8 +17,9 @@
|
|
|
15
17
|
* the {@link Candidate} contract defined here.
|
|
16
18
|
*
|
|
17
19
|
* Exports:
|
|
18
|
-
* enumerateCandidates —
|
|
19
|
-
* repo's free/busy status
|
|
20
|
+
* enumerateCandidates — walk a startDir up to {@link DEFAULT_MAX_DEPTH}
|
|
21
|
+
* levels deep, resolve each repo's free/busy status
|
|
22
|
+
* from its lease.
|
|
20
23
|
* freeCandidates — filter helper: keep only `free === true` candidates.
|
|
21
24
|
*
|
|
22
25
|
* No top-level side effects. All filesystem + lock access is dependency-injected
|
|
@@ -47,6 +50,53 @@ const STATUS_FREI = 'frei';
|
|
|
47
50
|
const STATUS_IN_PROGRESS = 'in-progress';
|
|
48
51
|
const STATUS_FORCE_CLOSED = 'force-closed';
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Fixed walk depth. `1` = immediate children of the scan root only (the
|
|
55
|
+
* pre-#832 behaviour); `2` additionally covers `<root>/<org>/<repo>`.
|
|
56
|
+
*
|
|
57
|
+
* Measured on the reference host (~/Projects, 2026-07-19; warm dentry cache —
|
|
58
|
+
* a cold first walk costs roughly 10x these figures at either depth):
|
|
59
|
+
* depth 1 → 1 of 47 repos (2%) — the real topology is `<org>/<repo>`,
|
|
60
|
+
* so the scan missed 46 repos including
|
|
61
|
+
* session-orchestrator itself.
|
|
62
|
+
* depth 2 → 45 of 47 repos, ~0.9-1.9ms
|
|
63
|
+
* depth 3 → 47 of 47 repos, ~8ms (node_modules is pruned, but the extra
|
|
64
|
+
* level still multiplies the node count)
|
|
65
|
+
* for two additional repos, both archived.
|
|
66
|
+
* Depth 2 is therefore the fixed bound: it recovers 96% of the host's repos
|
|
67
|
+
* at negligible cost, while depth 3 costs ~8x the walk for two dead repos.
|
|
68
|
+
*
|
|
69
|
+
* This was previously exposed as a caller-tunable `maxDepth` option (clamped
|
|
70
|
+
* 1..3). Issue #838 removed the knob: all three production callers
|
|
71
|
+
* (`scripts/lib/dispatcher/cli.mjs`, `scripts/lib/lock-reaper.mjs`,
|
|
72
|
+
* `scripts/lib/vault-status/board-writer.mjs`) always omitted it and relied on
|
|
73
|
+
* this default, and no CLI surface ever forwarded a caller-supplied value —
|
|
74
|
+
* a config knob whose only exercised path was its own test suite. The walk
|
|
75
|
+
* BOUND is unchanged; only the ability to override it at the call boundary
|
|
76
|
+
* is gone.
|
|
77
|
+
*/
|
|
78
|
+
const DEFAULT_MAX_DEPTH = 2;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Directory names never DESCENDED into during the walk.
|
|
82
|
+
*
|
|
83
|
+
* Applied to the descent decision ONLY — never to repo emission — so the
|
|
84
|
+
* depth-1 contract stays byte-identical to the pre-#832 scan (`.orchestrator`,
|
|
85
|
+
* `.claude` etc. are still probed for a `.git` marker at depth 1; they simply
|
|
86
|
+
* have none). `node_modules` is the dominant cost driver at depth 3 and can
|
|
87
|
+
* legitimately contain vendored `.git` directories that are not host repos.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} name — a single path segment (Dirent.name).
|
|
90
|
+
* @returns {boolean} true iff the walk may recurse into this directory.
|
|
91
|
+
*/
|
|
92
|
+
function shouldDescendInto(name) {
|
|
93
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
94
|
+
if (name === 'node_modules') return false;
|
|
95
|
+
// Dot-directories (.git, .claude, .orchestrator, .venv, …) hold no host repos.
|
|
96
|
+
if (name.startsWith('.')) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
/**
|
|
51
101
|
* Expand a leading `~` to the current user's home directory. Mirrors the helper
|
|
52
102
|
* in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
|
|
@@ -126,15 +176,26 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
126
176
|
* free or busy via its local lease.
|
|
127
177
|
*
|
|
128
178
|
* Algorithm:
|
|
129
|
-
* 1.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
179
|
+
* 1. Depth-first walk of `startDir`, up to {@link DEFAULT_MAX_DEPTH} levels
|
|
180
|
+
* deep (depth 1 = immediate children). Every directory node is a repo
|
|
181
|
+
* candidate iff `<node>/.git` exists (dir or file — the file form covers
|
|
182
|
+
* worktrees).
|
|
183
|
+
* 2. Confinement guard (`validatePathInsideProject(nodeAbs, startDir)`) runs
|
|
184
|
+
* on EVERY node BEFORE it is emitted AND before it is opened — see the
|
|
185
|
+
* security notes on the walk body below.
|
|
133
186
|
* 3. OPTIONAL secondary source: union with `getCrossRepoProjects()`
|
|
134
187
|
* config-declared paths (leading `~/` expanded, then confinement-filtered),
|
|
135
188
|
* deduped by `path.resolve()`. Additive, applied AFTER the FS scan.
|
|
136
189
|
* 4. Resolve free/busy per repo from its `session.lock` lease.
|
|
137
190
|
*
|
|
191
|
+
* A git repo does NOT terminate the descent: on a measured reference host, an
|
|
192
|
+
* org-level directory one level under the confinement root was itself a git
|
|
193
|
+
* repo (a small umbrella notes repo) that CONTAINED 16 independent repos —
|
|
194
|
+
* this plugin's own checkout among them. "A repo's children are not separate
|
|
195
|
+
* repos" is empirically false there, and an early-exit-on-`.git` walk dropped
|
|
196
|
+
* 45 discoverable repos to 29. Pruning is therefore by NAME
|
|
197
|
+
* ({@link shouldDescendInto}), never by `.git` presence.
|
|
198
|
+
*
|
|
138
199
|
* ALL repos are returned (busy ones LISTED, not dropped — downstream rank.mjs
|
|
139
200
|
* filters). Returns a plain serialisable {@link Candidate}[].
|
|
140
201
|
*
|
|
@@ -179,32 +240,77 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
|
|
|
179
240
|
repoPaths.push(resolved);
|
|
180
241
|
};
|
|
181
242
|
|
|
182
|
-
// ── 1+2. FS
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
243
|
+
// ── 1+2. Depth-capped FS walk, confinement-guarded at every node. ──
|
|
244
|
+
//
|
|
245
|
+
// SECURITY (three load-bearing invariants — do not relax without re-reading
|
|
246
|
+
// validatePathInsideProject at scripts/lib/path-utils.mjs):
|
|
247
|
+
//
|
|
248
|
+
// (i) Every node is validated against the ORIGINAL `root`, NEVER against
|
|
249
|
+
// its own parent. The guard's Phase 2 calls realpathSync, which
|
|
250
|
+
// resolves EVERY intermediate component — so validating a grandchild
|
|
251
|
+
// against the original root is both sufficient and complete at any
|
|
252
|
+
// depth. Re-rooting per level (`validate(grandchild, childDir)`) would
|
|
253
|
+
// validate a symlinked subtree against ITSELF and defeat the guard.
|
|
254
|
+
//
|
|
255
|
+
// (ii) The guard runs BEFORE `readdirSync`, not merely before emission.
|
|
256
|
+
// Pre-#832 the guard ran only after `isGitRepo` passed, which was safe
|
|
257
|
+
// because a non-repo directory was never opened. Under recursion an
|
|
258
|
+
// unguarded non-repo directory WOULD be opened, so an `ok:false` node
|
|
259
|
+
// must be refused for descent as well as for emission.
|
|
260
|
+
//
|
|
261
|
+
// (iii) The guard call is wrapped in try/catch. path-utils.mjs rethrows any
|
|
262
|
+
// non-ENOENT realpath error, so a single mode-000 directory under the
|
|
263
|
+
// scan root would otherwise throw straight out of enumerateCandidates
|
|
264
|
+
// — and runDispatch (scripts/lib/dispatcher/cli.mjs) has no try/catch
|
|
265
|
+
// around this call. The walk now validates ~52 nodes instead of 1, so
|
|
266
|
+
// a throwing guard is treated as "skip this node".
|
|
267
|
+
//
|
|
268
|
+
// Unbounded recursion is impossible: `Dirent.isDirectory()` is false for a
|
|
269
|
+
// symlink-to-directory (verified empirically), so symlink cycles never enter
|
|
270
|
+
// the walk — and `DEFAULT_MAX_DEPTH` bounds it regardless, including for
|
|
271
|
+
// stubbed entries that do not implement isDirectory().
|
|
272
|
+
const walk = (dirAbs, depth) => {
|
|
273
|
+
let entries;
|
|
274
|
+
try {
|
|
275
|
+
entries = readdirSyncFn(dirAbs, { withFileTypes: true });
|
|
276
|
+
} catch {
|
|
277
|
+
// Unreadable/absent directory → this subtree contributes nothing.
|
|
278
|
+
// Siblings and the config-declared secondary source are unaffected.
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
191
281
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
// Only directories can be repos. Dirent.isDirectory() guards against
|
|
284
|
+
// files, sockets, etc. A stubbed entry may not implement isDirectory —
|
|
285
|
+
// fall back to treating it as a directory candidate (the depth cap and
|
|
286
|
+
// existsSync('.git') gate the consequences).
|
|
287
|
+
const isDir = typeof entry?.isDirectory === 'function' ? entry.isDirectory() : true;
|
|
288
|
+
if (!isDir) continue;
|
|
198
289
|
|
|
199
|
-
|
|
200
|
-
if (!isGitRepo(childAbs, existsSyncFn)) continue;
|
|
290
|
+
const childAbs = path.join(dirAbs, entry.name);
|
|
201
291
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
292
|
+
// Confinement guard — invariants (i)+(ii)+(iii) above.
|
|
293
|
+
let guard;
|
|
294
|
+
try {
|
|
295
|
+
guard = validatePathInsideProjectFn(childAbs, root);
|
|
296
|
+
} catch {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!guard || guard.ok !== true) continue;
|
|
205
300
|
|
|
206
|
-
|
|
207
|
-
|
|
301
|
+
if (isGitRepo(childAbs, existsSyncFn)) addRepo(childAbs);
|
|
302
|
+
|
|
303
|
+
// Descent is INDEPENDENT of repo-ness: a repo may contain further repos
|
|
304
|
+
// (the umbrella-repo case documented above). Prune by name only, and only
|
|
305
|
+
// for the descent decision — emission above is untouched, which keeps the
|
|
306
|
+
// depth-1 contract byte-identical to the pre-#832 scan.
|
|
307
|
+
if (depth < DEFAULT_MAX_DEPTH && shouldDescendInto(entry.name)) {
|
|
308
|
+
walk(childAbs, depth + 1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
walk(root, 1);
|
|
208
314
|
|
|
209
315
|
// ── 3. Optional secondary source: config-declared cross-repo projects. ──
|
|
210
316
|
let declared;
|
|
@@ -35,6 +35,7 @@ import path from 'node:path';
|
|
|
35
35
|
import { scanBacklog } from '../backlog-scan.mjs';
|
|
36
36
|
import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
|
|
37
37
|
import { probe as realProbe, evaluate as realEvaluate } from '../resource-probe.mjs';
|
|
38
|
+
import { isRealSession } from '../session-schema/filters.mjs';
|
|
38
39
|
|
|
39
40
|
/** Staleness cap (days). Beyond this, additional age does not raise the score. */
|
|
40
41
|
export const STALENESS_CAP_DAYS = 90;
|
|
@@ -156,13 +157,21 @@ async function defaultFetchPriority(repoRoot, nowMs) {
|
|
|
156
157
|
|
|
157
158
|
/**
|
|
158
159
|
* Default STALENESS source: read `<repoRoot>/.orchestrator/metrics/sessions.jsonl`,
|
|
159
|
-
*
|
|
160
|
-
* `started_at`). No file / no
|
|
161
|
-
* `STALENESS_CAP_DAYS` (treat as
|
|
160
|
+
* find the last REAL (non-phantom) record scanning backward from the tail, and
|
|
161
|
+
* compute days since `completed_at` (fallback `started_at`). No file / no
|
|
162
|
+
* parsable REAL record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as
|
|
163
|
+
* maximally stale = most worthwhile).
|
|
164
|
+
*
|
|
165
|
+
* Scans backward PAST any trailing `status: 'abandoned'` phantom stubs (#834)
|
|
166
|
+
* — session-close-backfill writes these for sessions that ended without a real
|
|
167
|
+
* close (0 waves, seconds of runtime). Stopping at the raw last LINE would let
|
|
168
|
+
* a single recent phantom make a genuinely neglected repo look freshly
|
|
169
|
+
* touched, defeating the dispatcher's whole purpose (this is the N=1 extreme
|
|
170
|
+
* case of the phantom-tail problem — one stub is enough to zero out staleness).
|
|
162
171
|
*
|
|
163
172
|
* @param {string} repoRoot
|
|
164
173
|
* @param {number} nowMs
|
|
165
|
-
* @returns {Promise<number>} days since last session (≥ 0)
|
|
174
|
+
* @returns {Promise<number>} days since last REAL session (≥ 0)
|
|
166
175
|
*/
|
|
167
176
|
async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
168
177
|
try {
|
|
@@ -171,14 +180,19 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
|
171
180
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
172
181
|
if (lines.length === 0) return STALENESS_CAP_DAYS;
|
|
173
182
|
|
|
174
|
-
//
|
|
183
|
+
// Scan backward for the last REAL (non-abandoned) session record, skipping
|
|
184
|
+
// both corrupt lines and phantom stubs.
|
|
175
185
|
let last = null;
|
|
176
186
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
187
|
+
let parsed;
|
|
177
188
|
try {
|
|
178
|
-
|
|
179
|
-
break;
|
|
189
|
+
parsed = JSON.parse(lines[i]);
|
|
180
190
|
} catch {
|
|
181
|
-
// Skip a corrupt
|
|
191
|
+
continue; // Skip a corrupt line and try the previous one.
|
|
192
|
+
}
|
|
193
|
+
if (isRealSession(parsed)) {
|
|
194
|
+
last = parsed;
|
|
195
|
+
break;
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
if (!last || typeof last !== 'object') return STALENESS_CAP_DAYS;
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
findSessionConfigBlock,
|
|
10
|
+
SESSION_CONFIG_HEADING,
|
|
11
|
+
} from '../config/section-extractor.mjs';
|
|
8
12
|
|
|
9
13
|
// ---------------------------------------------------------------------------
|
|
10
14
|
// Validation
|
|
@@ -75,18 +79,14 @@ export function validateEcosystemPolicy(policy) {
|
|
|
75
79
|
// ---------------------------------------------------------------------------
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
|
-
* Returns whether the Session Config section
|
|
82
|
+
* Returns whether the Session Config section already contains an
|
|
79
83
|
* ecosystem-health key.
|
|
80
|
-
* @param {
|
|
81
|
-
* @param {RegExpMatchArray|null} startMatch
|
|
84
|
+
* @param {ReturnType<typeof findSessionConfigBlock>} block
|
|
82
85
|
* @returns {boolean}
|
|
83
86
|
*/
|
|
84
|
-
function _ecosystemBlockPresent(
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
const nextHeadingMatch = afterBlock.match(/^## /m);
|
|
88
|
-
const blockContent = nextHeadingMatch ? afterBlock.slice(0, nextHeadingMatch.index) : afterBlock;
|
|
89
|
-
return /^\s*ecosystem-health\s*:/m.test(blockContent);
|
|
87
|
+
function _ecosystemBlockPresent(block) {
|
|
88
|
+
if (!block) return false;
|
|
89
|
+
return /^\s*ecosystem-health\s*:/m.test(block.body);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
@@ -104,23 +104,21 @@ function _replaceEcosystemBlock(text, snippetLines) {
|
|
|
104
104
|
* Inserts a new ecosystem-health snippet into `text` within the Session Config
|
|
105
105
|
* section, or appends a new section when none exists.
|
|
106
106
|
* @param {string} text
|
|
107
|
-
* @param {
|
|
107
|
+
* @param {ReturnType<typeof findSessionConfigBlock>} block
|
|
108
108
|
* @param {string[]} snippetLines
|
|
109
109
|
* @returns {string}
|
|
110
110
|
*/
|
|
111
|
-
function _insertEcosystemBlock(text,
|
|
111
|
+
function _insertEcosystemBlock(text, block, snippetLines) {
|
|
112
112
|
const snippet = '\n' + snippetLines.join('\n');
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const nextHeadingMatch = afterBlock.match(/^## /m);
|
|
119
|
-
if (nextHeadingMatch && nextHeadingMatch.index !== undefined) {
|
|
120
|
-
const insertAt = afterStartIdx + nextHeadingMatch.index;
|
|
121
|
-
return text.slice(0, insertAt) + snippet + '\n' + text.slice(insertAt);
|
|
113
|
+
if (!block) {
|
|
114
|
+
// The one site in this repo that WRITES the heading. It takes the literal
|
|
115
|
+
// from the SSOT so a producer/comparator drift cannot open the same
|
|
116
|
+
// silent-fallback hole the predicate exists to close (#968).
|
|
117
|
+
return text + '\n' + SESSION_CONFIG_HEADING + '\n' + snippet + '\n';
|
|
122
118
|
}
|
|
123
|
-
|
|
119
|
+
// `bodyEnd` is the offset of the next `## ` heading, or EOF — i.e. exactly
|
|
120
|
+
// the end of the Session Config section, which is where the snippet goes.
|
|
121
|
+
return text.slice(0, block.bodyEnd) + snippet + '\n' + text.slice(block.bodyEnd);
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
/**
|
|
@@ -211,10 +209,14 @@ export function writeSessionConfigBlock(configFilePath, config, dryRun, overwrit
|
|
|
211
209
|
return 'error';
|
|
212
210
|
}
|
|
213
211
|
|
|
214
|
-
|
|
212
|
+
// Heading + body span from the SSOT (#968) — the previous local regex
|
|
213
|
+
// `/^## Session Config[ \t]*(?:\r?\n|$)/m` accepted a trailing-whitespace
|
|
214
|
+
// heading the runtime parser rejects, so the wizard would write a well-formed
|
|
215
|
+
// ecosystem-health block into a section `parseSessionConfig` never reads.
|
|
216
|
+
const block = findSessionConfigBlock(text);
|
|
215
217
|
const snippetLines = _buildEcosystemSnippetLines(config);
|
|
216
218
|
|
|
217
|
-
if (_ecosystemBlockPresent(
|
|
219
|
+
if (_ecosystemBlockPresent(block)) {
|
|
218
220
|
if (!overwrite) return 'skipped';
|
|
219
221
|
// Replace the existing ecosystem-health block with the new snippet.
|
|
220
222
|
// The block spans from "ecosystem-health:" to the next top-level key or heading.
|
|
@@ -230,7 +232,7 @@ export function writeSessionConfigBlock(configFilePath, config, dryRun, overwrit
|
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
// Insert path: append ecosystem-health block to Session Config section
|
|
233
|
-
const newText = _insertEcosystemBlock(text,
|
|
235
|
+
const newText = _insertEcosystemBlock(text, block, snippetLines);
|
|
234
236
|
if (!dryRun) {
|
|
235
237
|
try { writeFileSync(configFilePath, newText, 'utf8'); } catch { return 'error'; }
|
|
236
238
|
}
|
|
@@ -72,7 +72,7 @@ async function _promptInteractiveAnswers(existingConfig, ciProvider, packageMana
|
|
|
72
72
|
: `CI pipeline identifiers (format "id" or "id:label", comma-separated, blank to skip):\n> `;
|
|
73
73
|
const labelsPrompt = currentLabels
|
|
74
74
|
? `Critical issue labels [current: ${currentLabels}]:\n> `
|
|
75
|
-
: `Critical issue labels (comma-separated, e.g. "priority
|
|
75
|
+
: `Critical issue labels (comma-separated, e.g. "priority::critical,severity:blocker", blank to skip):\n> `;
|
|
76
76
|
|
|
77
77
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
78
78
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
@@ -34,6 +34,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
34
34
|
import os from 'node:os';
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
|
|
37
|
+
import { resolvePluginRoot } from '../common.mjs';
|
|
37
38
|
import { readJsonlFile } from '../io.mjs';
|
|
38
39
|
import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
|
|
39
40
|
import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
|
|
@@ -41,8 +42,33 @@ import { resolveSession, computeWindow, findPeerOverlap } from './session-resolv
|
|
|
41
42
|
/** The rubric version this engine scores against. */
|
|
42
43
|
export const RUBRIC_VERSION = 'rubric-v1';
|
|
43
44
|
|
|
44
|
-
/**
|
|
45
|
-
export const
|
|
45
|
+
/** Rubric location relative to the plugin root. */
|
|
46
|
+
export const RUBRIC_RELATIVE_PATH = 'skills/eval/rubric-v1.md';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default rubric location, resolved against the PLUGIN root rather than the
|
|
50
|
+
* caller's cwd (#927).
|
|
51
|
+
*
|
|
52
|
+
* The previous cwd-relative literal only ever resolved when `/eval` happened to
|
|
53
|
+
* run from the plugin checkout itself. In a consumer repo — the normal install
|
|
54
|
+
* shape — it pointed at a non-existent `<consumer>/skills/eval/rubric-v1.md`,
|
|
55
|
+
* `computeRubricHash` returned null, and `validateEvalRecord` then rejected the
|
|
56
|
+
* record on `provenance.rubric_sha256` (schema.mjs), so the run produced no
|
|
57
|
+
* output at all.
|
|
58
|
+
*
|
|
59
|
+
* Resolution is best-effort by design: `resolvePluginRoot` THROWS when it cannot
|
|
60
|
+
* locate a plugin root, and this constant is evaluated at module load. A throw
|
|
61
|
+
* here would make the module unimportable, so we degrade to the relative literal
|
|
62
|
+
* — preserving the previous behaviour instead of turning a degraded path into a
|
|
63
|
+
* hard import failure.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_RUBRIC_PATH = (() => {
|
|
66
|
+
try {
|
|
67
|
+
return path.join(resolvePluginRoot(import.meta.url), RUBRIC_RELATIVE_PATH);
|
|
68
|
+
} catch {
|
|
69
|
+
return RUBRIC_RELATIVE_PATH;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
46
72
|
|
|
47
73
|
/** Ordered rubric-v1 dimension ids — the canonical scoring order. */
|
|
48
74
|
export const RUBRIC_DIMENSION_IDS = Object.freeze([
|
|
@@ -372,13 +398,29 @@ function extractKpis(record) {
|
|
|
372
398
|
// Provenance / harness / model helpers
|
|
373
399
|
// ---------------------------------------------------------------------------
|
|
374
400
|
|
|
375
|
-
/**
|
|
401
|
+
/**
|
|
402
|
+
* sha256 hex of the rubric file, or null when it cannot be read.
|
|
403
|
+
*
|
|
404
|
+
* A null return is NOT benign: `validateEvalRecord` requires a non-empty
|
|
405
|
+
* `provenance.rubric_sha256`, so a miss here aborts the whole append downstream.
|
|
406
|
+
* The downstream WARNs name the validation failure but never the path that was
|
|
407
|
+
* tried, which is the one fact needed to diagnose it — so name it here (#927).
|
|
408
|
+
*/
|
|
376
409
|
function computeRubricHash(rubricPath) {
|
|
377
410
|
try {
|
|
378
|
-
if (!rubricPath || !existsSync(rubricPath))
|
|
411
|
+
if (!rubricPath || !existsSync(rubricPath)) {
|
|
412
|
+
process.stderr.write(
|
|
413
|
+
`[eval-engine] WARN: rubric not found at '${rubricPath ?? '<unset>'}' — ` +
|
|
414
|
+
'provenance.rubric_sha256 will be null and the record will fail validation.\n',
|
|
415
|
+
);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
379
418
|
const buf = readFileSync(rubricPath);
|
|
380
419
|
return createHash('sha256').update(buf).digest('hex');
|
|
381
|
-
} catch {
|
|
420
|
+
} catch (err) {
|
|
421
|
+
process.stderr.write(
|
|
422
|
+
`[eval-engine] WARN: could not hash rubric at '${rubricPath}': ${err?.message ?? String(err)}\n`,
|
|
423
|
+
);
|
|
382
424
|
return null;
|
|
383
425
|
}
|
|
384
426
|
}
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -12,17 +12,61 @@
|
|
|
12
12
|
import { promises as fs } from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { SO_PROJECT_DIR, SO_SHARED_DIR } from './platform.mjs';
|
|
15
|
+
import { readLock } from './session-lock.mjs';
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Public API
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* Returns the absolute path to
|
|
22
|
+
* Returns the absolute path to `.orchestrator/metrics/events.jsonl` under `repoRoot`.
|
|
23
|
+
*
|
|
24
|
+
* `repoRoot` defaults to the module-level `SO_PROJECT_DIR` constant, so the
|
|
25
|
+
* zero-arg call is unchanged for every existing caller (#941). Pass an explicit
|
|
26
|
+
* `repoRoot` when the destination must be pinned to a tree other than the
|
|
27
|
+
* CWD/env-resolved project — e.g. a unit test running the gate against a tmp
|
|
28
|
+
* repo, which must NOT append synthetic records to the real fleet telemetry.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} [repoRoot=SO_PROJECT_DIR] — project root the events log lives under.
|
|
22
31
|
* @returns {string}
|
|
23
32
|
*/
|
|
24
|
-
export function eventsFilePath() {
|
|
25
|
-
return path.join(
|
|
33
|
+
export function eventsFilePath(repoRoot = SO_PROJECT_DIR) {
|
|
34
|
+
return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Session attribution for gate/lifecycle telemetry (#928a, hoisted here #941).
|
|
39
|
+
*
|
|
40
|
+
* The natural shared home the two former call-site copies (`quality-gate.mjs`,
|
|
41
|
+
* `run-quality-gate.mjs`) both named. Emits BOTH the UUID `session_id` and the
|
|
42
|
+
* `semantic_session_id`, mirroring the field shape of
|
|
43
|
+
* `orchestrator.session.lock.acquired` so gate events join against the same keys
|
|
44
|
+
* existing consumers already read (see `session-close-backfill.mjs`).
|
|
45
|
+
*
|
|
46
|
+
* Without a lock (CI runs have none) BOTH keys are OMITTED rather than filled
|
|
47
|
+
* with a placeholder: a fabricated id would silently collide across every
|
|
48
|
+
* unattributed run and read as a real session; an empty string would satisfy a
|
|
49
|
+
* truthiness check while attributing to nothing. An absent key is the only
|
|
50
|
+
* honest encoding of "not attributable".
|
|
51
|
+
*
|
|
52
|
+
* @param {string} [repoRoot] — repo whose `session.lock` is read for attribution.
|
|
53
|
+
* @returns {{session_id?: string, semantic_session_id?: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function sessionAttribution(repoRoot) {
|
|
56
|
+
try {
|
|
57
|
+
const lock = readLock({ repoRoot });
|
|
58
|
+
if (!lock) return {};
|
|
59
|
+
const out = {};
|
|
60
|
+
if (typeof lock.session_id === 'string' && lock.session_id.trim()) {
|
|
61
|
+
out.session_id = lock.session_id;
|
|
62
|
+
}
|
|
63
|
+
if (typeof lock.semantic_session_id === 'string' && lock.semantic_session_id.trim()) {
|
|
64
|
+
out.semantic_session_id = lock.semantic_session_id;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
/**
|
|
@@ -43,6 +87,11 @@ export function eventsFilePath() {
|
|
|
43
87
|
* Used by `scripts/emit-event.mjs --file` so shell callers (e.g.
|
|
44
88
|
* compute-grounding-injection.sh) can target a pre-resolved EVENTS_JSONL path
|
|
45
89
|
* without depending on platform.mjs CWD/env resolution (#611).
|
|
90
|
+
* @param {string} [opts.repoRoot] — pin the destination to `<repoRoot>/.orchestrator/
|
|
91
|
+
* metrics/events.jsonl` instead of the module-level `SO_PROJECT_DIR` default
|
|
92
|
+
* (#941). Ignored when `opts.filePath` is given (explicit path wins). This is
|
|
93
|
+
* the clean interface replacing the hand-built `join(repoRoot, …)` recipes that
|
|
94
|
+
* used to open-code this destination at each call-site.
|
|
46
95
|
* @returns {Promise<void>}
|
|
47
96
|
*/
|
|
48
97
|
export async function emitEvent(type, payload = {}, opts = {}) {
|
|
@@ -50,10 +99,13 @@ export async function emitEvent(type, payload = {}, opts = {}) {
|
|
|
50
99
|
const record = { timestamp: new Date().toISOString(), event: type, ...payload };
|
|
51
100
|
const line = JSON.stringify(record) + '\n';
|
|
52
101
|
|
|
53
|
-
// Ensure the destination directory exists before appending.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
102
|
+
// Ensure the destination directory exists before appending. Resolution order:
|
|
103
|
+
// 1. explicit opts.filePath (a pre-resolved path — #611)
|
|
104
|
+
// 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
|
|
105
|
+
// 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
|
|
106
|
+
// eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default param,
|
|
107
|
+
// so a caller passing neither behaves EXACTLY as before (additive).
|
|
108
|
+
const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
|
|
57
109
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
58
110
|
await fs.appendFile(filePath, line, 'utf8');
|
|
59
111
|
|
|
@@ -223,6 +223,11 @@ function readinessConfidence(autopilotSummary, judgmentSummary, score) {
|
|
|
223
223
|
/**
|
|
224
224
|
* Summarize autopilot run history plus type-8 mode effectiveness rollups.
|
|
225
225
|
*
|
|
226
|
+
* Abandoned-session filtering (#834): `sessions` is passed straight through
|
|
227
|
+
* to `groupByMode()`, which filters phantom `status: 'abandoned'` stubs
|
|
228
|
+
* before bucketing — this function inherits that guarantee transitively and
|
|
229
|
+
* does not duplicate the filter. See `autopilot-effectiveness.mjs` `groupByMode()`.
|
|
230
|
+
*
|
|
226
231
|
* @param {Array} autopilotRuns
|
|
227
232
|
* @param {Array} sessions
|
|
228
233
|
* @returns {object}
|