session-orchestrator 3.21.0 → 3.22.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/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +101 -0
- package/README.md +10 -10
- package/agents/memory-proposal-collector.md +6 -4
- package/commands/eli5.md +33 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/scope-collision-guard.md +3 -3
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +24 -92
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +7 -8
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/ci-status-banner.mjs +63 -57
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +147 -50
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +6 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +32 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/discovery/SKILL.md +28 -24
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/session-end/SKILL.md +53 -20
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-start/SKILL.md +69 -35
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +27 -5
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -227,17 +227,56 @@ const WRONG_FAMILY_HOST = {
|
|
|
227
227
|
github: 'gitlab.com',
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
/** URI schemes that can name a supported Git remote. */
|
|
231
|
+
const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
|
|
232
|
+
|
|
230
233
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
+
* Parse a URI-style remote only when it uses one of this module's supported
|
|
235
|
+
* protocols. The `URL` parser makes hostname/port handling consistent between
|
|
236
|
+
* HTTP(S) and URI-style SSH while scp-style SSH stays a separate grammar.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} url
|
|
239
|
+
* @returns {URL|null}
|
|
240
|
+
*/
|
|
241
|
+
function parseRemoteUri(url) {
|
|
242
|
+
if (typeof url !== 'string' || !/^(?:https?|ssh):\/\//i.test(url)) return null;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
return REMOTE_URI_PROTOCOLS.has(parsed.protocol) ? parsed : null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract the operational host from a git remote URL, handling HTTPS
|
|
253
|
+
* (`https://host/owner/repo.git`), scp-style SSH (`git@host:owner/repo.git`),
|
|
254
|
+
* and URI-style SSH (`ssh://git@host/owner/repo.git`) forms. A non-default
|
|
255
|
+
* URI port is preserved because callers may need it to address a self-hosted
|
|
256
|
+
* instance. Returns `null` for an unrecognized shape (never throws).
|
|
234
257
|
*
|
|
235
258
|
* @param {string} url
|
|
236
259
|
* @returns {string|null}
|
|
237
260
|
*/
|
|
238
261
|
function extractHost(url) {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
262
|
+
const parsed = parseRemoteUri(url);
|
|
263
|
+
if (parsed !== null) return parsed.host.toLowerCase() || null;
|
|
264
|
+
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
265
|
+
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Extract a bare hostname for VCS-family comparisons. This deliberately drops
|
|
271
|
+
* a URI port: `github.com:443` is still the public GitHub host, while the
|
|
272
|
+
* operational `extractHost()` value retains a non-default self-hosted port.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} url
|
|
275
|
+
* @returns {string|null}
|
|
276
|
+
*/
|
|
277
|
+
function extractHostname(url) {
|
|
278
|
+
const parsed = parseRemoteUri(url);
|
|
279
|
+
if (parsed !== null) return parsed.hostname.toLowerCase() || null;
|
|
241
280
|
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
242
281
|
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
243
282
|
return null;
|
|
@@ -367,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
|
|
|
367
406
|
}
|
|
368
407
|
|
|
369
408
|
/**
|
|
370
|
-
* Resolve the
|
|
371
|
-
* `glab api --hostname`/`gh api --hostname
|
|
372
|
-
* CLIs does NOT accept `-R`/`--repo` (it has no
|
|
373
|
-
* `--hostname`
|
|
374
|
-
* host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
375
|
-
* sites.
|
|
409
|
+
* Resolve the operational host of the matching remote, preserving a
|
|
410
|
+
* non-default self-hosted port for `glab api --hostname`/`gh api --hostname`.
|
|
411
|
+
* The `api` subcommand of both CLIs does NOT accept `-R`/`--repo` (it has no
|
|
412
|
+
* repo concept), only `--hostname` to pin which instance the request targets.
|
|
413
|
+
* This is the host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
414
|
+
* call sites.
|
|
376
415
|
*
|
|
377
416
|
* Applies the identical remote-preference-order + cross-family-guard
|
|
378
417
|
* resolution as `resolveRepoSpec`, just returning the host instead of the
|
|
@@ -393,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
|
|
|
393
432
|
return isUnsafeForArgv(host) ? undefined : host;
|
|
394
433
|
}
|
|
395
434
|
|
|
435
|
+
/** Project-path characters that cannot name a GitLab namespace/project. */
|
|
436
|
+
// eslint-disable-next-line no-control-regex -- validate every C0/C1 control before encoding a GitLab API path
|
|
437
|
+
const UNSAFE_PROJECT_PATH_CHARS_RE = /[\\\x00-\x1f\x7f-\x9f?#]/;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Decode a transport project path exactly once, then validate its canonical
|
|
441
|
+
* namespace/project shape. A percent sign surviving the one decode is rejected
|
|
442
|
+
* because `%252e%252e` is indistinguishable from a literal encoded escape; do
|
|
443
|
+
* not normalize that ambiguity into an API target.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} rawProjectPath
|
|
446
|
+
* @param {{ uriPath: boolean }} opts
|
|
447
|
+
* @returns {string|undefined}
|
|
448
|
+
*/
|
|
449
|
+
function normalizeGitlabProjectPath(rawProjectPath, { uriPath }) {
|
|
450
|
+
if (typeof rawProjectPath !== 'string') return undefined;
|
|
451
|
+
|
|
452
|
+
let projectPath = rawProjectPath;
|
|
453
|
+
if (uriPath) {
|
|
454
|
+
// URI syntax supplies one separator before the path. More than one is a
|
|
455
|
+
// path segment, not syntax to trim away.
|
|
456
|
+
if (!projectPath.startsWith('/') || projectPath.startsWith('//')) return undefined;
|
|
457
|
+
projectPath = projectPath.slice(1);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
projectPath === '' ||
|
|
462
|
+
projectPath.startsWith('/') ||
|
|
463
|
+
projectPath.endsWith('/') ||
|
|
464
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(projectPath)
|
|
465
|
+
) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let decodedProjectPath;
|
|
470
|
+
try {
|
|
471
|
+
decodedProjectPath = decodeURIComponent(projectPath);
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
decodedProjectPath.includes('%') ||
|
|
478
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(decodedProjectPath) ||
|
|
479
|
+
isUnsafeForArgv(decodedProjectPath)
|
|
480
|
+
) {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const withoutGitSuffix = decodedProjectPath.replace(/\.git$/i, '');
|
|
485
|
+
const segments = withoutGitSuffix.split('/');
|
|
486
|
+
if (
|
|
487
|
+
withoutGitSuffix === '' ||
|
|
488
|
+
withoutGitSuffix.startsWith('/') ||
|
|
489
|
+
withoutGitSuffix.endsWith('/') ||
|
|
490
|
+
segments.length < 2 ||
|
|
491
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
492
|
+
) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return withoutGitSuffix;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Extract an operational GitLab host and the raw (not URL-normalized) project
|
|
501
|
+
* path. URI parsing owns authority/port validation, while the raw path keeps
|
|
502
|
+
* dot and percent-encoded traversal visible to {@link normalizeGitlabProjectPath}
|
|
503
|
+
* before WHATWG URL normalization could erase it.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} url
|
|
506
|
+
* @returns {{ host: string, rawProjectPath: string, uriPath: boolean }|undefined}
|
|
507
|
+
*/
|
|
508
|
+
function extractGitlabProjectTargetParts(url) {
|
|
509
|
+
const scpMatch = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url);
|
|
510
|
+
if (scpMatch) {
|
|
511
|
+
return { host: scpMatch[1], rawProjectPath: scpMatch[2], uriPath: false };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const parsed = parseRemoteUri(url);
|
|
515
|
+
if (
|
|
516
|
+
parsed === null ||
|
|
517
|
+
parsed.search !== '' ||
|
|
518
|
+
parsed.hash !== '' ||
|
|
519
|
+
((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
520
|
+
(parsed.username !== '' || parsed.password !== ''))
|
|
521
|
+
) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// `parsed.pathname` is intentionally NOT used: the URL parser resolves
|
|
526
|
+
// `.`/`..` before this boundary can reject them. The regex reads only the
|
|
527
|
+
// path from an already-parsed, supported URI.
|
|
528
|
+
const rawPathMatch = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?(?:[?#].*)?$/i.exec(url);
|
|
529
|
+
if (rawPathMatch === null) return undefined;
|
|
530
|
+
return { host: parsed.host, rawProjectPath: rawPathMatch[1] ?? '', uriPath: true };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Derive the API target for a GitLab project from the same sanitized,
|
|
535
|
+
* preference-selected remote that powers {@link resolveRepoSpec}. GitLab's REST
|
|
536
|
+
* API takes a URL-encoded `namespace/project` path rather than a remote URL or
|
|
537
|
+
* numeric project ID, so this projection removes the ambient project-metadata
|
|
538
|
+
* lookup from callers that need to pin both project and host.
|
|
539
|
+
*
|
|
540
|
+
* Supports HTTPS, scp-style SSH, and `ssh://` remotes. Credential stripping
|
|
541
|
+
* happens upstream in {@link listRemotes}; this helper returns only an
|
|
542
|
+
* operational host (including a non-default self-hosted port) and a once-only
|
|
543
|
+
* encoded project path, never a remote URL or HTTP userinfo. An SSH login such
|
|
544
|
+
* as `git@host` is transport identity, not project-path userinfo.
|
|
545
|
+
*
|
|
546
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
|
|
547
|
+
* @returns {{ host: string, encodedProjectPath: string }|undefined}
|
|
548
|
+
*/
|
|
549
|
+
export function resolveGitlabProjectTarget({ repoRoot, gitRun = defaultGitRun } = {}) {
|
|
550
|
+
const url = resolveRawRemoteUrl({ repoRoot, vcs: 'gitlab', gitRun });
|
|
551
|
+
if (!url) return undefined;
|
|
552
|
+
|
|
553
|
+
const parts = extractGitlabProjectTargetParts(url);
|
|
554
|
+
if (!parts || isUnsafeForArgv(parts.host)) return undefined;
|
|
555
|
+
|
|
556
|
+
const projectPath = normalizeGitlabProjectPath(parts.rawProjectPath, { uriPath: parts.uriPath });
|
|
557
|
+
if (!projectPath) return undefined;
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
host: parts.host.toLowerCase(),
|
|
561
|
+
encodedProjectPath: encodeURIComponent(projectPath),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
396
565
|
/**
|
|
397
566
|
* @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
|
|
398
567
|
* `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
|
|
@@ -636,7 +805,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
|
|
|
636
805
|
const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
|
|
637
806
|
const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
|
|
638
807
|
const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
|
|
639
|
-
const isWrongFamily = (url) => wrongFamilyHost !== null &&
|
|
808
|
+
const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
|
|
640
809
|
|
|
641
810
|
/** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
|
|
642
811
|
const accept = (remote, via) =>
|
|
@@ -670,7 +839,7 @@ export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun }
|
|
|
670
839
|
* @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
|
|
671
840
|
*/
|
|
672
841
|
function classifyRemoteFamily(remote) {
|
|
673
|
-
const host =
|
|
842
|
+
const host = extractHostname(remote.url);
|
|
674
843
|
if (host !== null) {
|
|
675
844
|
if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
|
|
676
845
|
if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Part of v3.1.0 Epic #157, Sub-Epic resource-gate. Issue #193.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { probe } from './resource-probe.mjs';
|
|
10
|
+
import { probe, evaluate } from './resource-probe.mjs';
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Public API
|
|
@@ -30,10 +30,23 @@ async function extractMeasurements(opts) {
|
|
|
30
30
|
ramFreeGb: probeOverride.ramFreeGb,
|
|
31
31
|
// Tests may supply ramAvailableGb to exercise the macOS path; absent → null.
|
|
32
32
|
ramAvailableGb: probeOverride.ramAvailableGb ?? null,
|
|
33
|
+
// macOS memory_pressure — the highest-precedence memory signal (#1089).
|
|
34
|
+
memoryPressurePctFree: probeOverride.memoryPressurePctFree ?? null,
|
|
33
35
|
cpuLoadPct: probeOverride.cpuLoadPct,
|
|
34
36
|
// 5m-average CPU pct (#943); absent → null (legacy overrides → 1m-only judging).
|
|
35
37
|
cpuLoad5mPct: probeOverride.cpuLoad5mPct ?? null,
|
|
38
|
+
// #1089: live peer SESSIONS from the registry — the unit
|
|
39
|
+
// `concurrent-sessions-warn` is named for. Absent → null, and the
|
|
40
|
+
// rescaled process-count fallback applies.
|
|
41
|
+
peerSessions: probeOverride.peerSessions ?? null,
|
|
42
|
+
// Raw Claude PROCESS count. Historically (and misleadingly) named
|
|
43
|
+
// `concurrentSessions` on this override object; kept as an accepted alias
|
|
44
|
+
// so existing callers keep working, but it is compared against a
|
|
45
|
+
// process-denominated threshold now, never a session-denominated one.
|
|
46
|
+
claudeProcesses: probeOverride.claudeProcesses ?? probeOverride.concurrentSessions ?? null,
|
|
36
47
|
concurrentSessions: probeOverride.concurrentSessions,
|
|
48
|
+
swapUsedMb: probeOverride.swapUsedMb ?? null,
|
|
49
|
+
zombieProcesses: probeOverride.zombieProcesses ?? null,
|
|
37
50
|
};
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -48,12 +61,22 @@ async function extractMeasurements(opts) {
|
|
|
48
61
|
// macOS: free + reclaimable (vm_stat). null on Linux/Windows where
|
|
49
62
|
// os.freemem() is already accurate. (#667)
|
|
50
63
|
ramAvailableGb: snapshot.ram_available_gb ?? null,
|
|
64
|
+
// macOS memory_pressure — outranks both of the above (#1089).
|
|
65
|
+
memoryPressurePctFree: snapshot.memory_pressure_pct_free ?? null,
|
|
51
66
|
cpuLoadPct: snapshot.cpu_load_pct,
|
|
52
67
|
// 5m load-average as pct-of-cores (#943). null on Windows/zero-load, where
|
|
53
68
|
// the gate falls back to judging the 1m-derived cpu_load_pct alone.
|
|
54
69
|
cpuLoad5mPct: snapshot.cpu_load_5m_pct ?? null,
|
|
55
|
-
//
|
|
70
|
+
// #1089: live peer SESSIONS (registry, self excluded) — what
|
|
71
|
+
// `concurrent-sessions-warn` was always named for. This line used to read
|
|
72
|
+
// `concurrentSessions: snapshot.claude_processes_count`, a measured 6x unit
|
|
73
|
+
// error that made the gate reduce waves on essentially every dispatch.
|
|
74
|
+
peerSessions: snapshot.peer_sessions_count ?? null,
|
|
75
|
+
claudeProcesses: snapshot.claude_processes_count ?? null,
|
|
76
|
+
// Retained for the returned `measurements` object, which callers log.
|
|
56
77
|
concurrentSessions: snapshot.claude_processes_count ?? 0,
|
|
78
|
+
swapUsedMb: snapshot.swap_used_mb ?? null,
|
|
79
|
+
zombieProcesses: snapshot.zombie_processes_count ?? null,
|
|
57
80
|
};
|
|
58
81
|
}
|
|
59
82
|
|
|
@@ -131,29 +154,32 @@ function applyHeavyRepoCap(result, opts) {
|
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
/**
|
|
134
|
-
*
|
|
135
|
-
* Extracted so `applyDecisionRules` can layer the HR-004 heavy-repo cap on
|
|
136
|
-
* top without duplicating this sequence.
|
|
157
|
+
* Resource-driven decision, delegated to `evaluate()` (#1089).
|
|
137
158
|
*
|
|
138
|
-
*
|
|
159
|
+
* This function used to carry its OWN copy of the RAM/CPU/concurrency rules,
|
|
160
|
+
* running in sequence with first-match-wins. That duplication is exactly why
|
|
161
|
+
* the #667 available-RAM correction only ever landed halfway: it was applied
|
|
162
|
+
* here and in `evaluate()` separately, and the memory_pressure precedence that
|
|
163
|
+
* followed reached only one of the two. There is now one rule engine and this
|
|
164
|
+
* is a translation layer over it.
|
|
165
|
+
*
|
|
166
|
+
* Verdict → decision mapping:
|
|
167
|
+
* critical (hard signal) → coordinator-direct, 0 agents
|
|
168
|
+
* warn (2+ soft signals) → reduce, plannedAgents / 2 (floor 1)
|
|
169
|
+
* green (0-1 soft signals) → proceed at plannedAgents, reasons retained
|
|
170
|
+
*
|
|
171
|
+
* The halving on `warn` is this gate's own policy and deliberately differs
|
|
172
|
+
* from `evaluate()`'s flat cap of 2: the gate knows `plannedAgents` (a wave of
|
|
173
|
+
* 3 should not be "capped" UP to nothing), `evaluate()` does not.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} measurements — from extractMeasurements
|
|
139
176
|
* @param {object} opts - Same opts shape as evaluateWaveResourceGate
|
|
140
177
|
* @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
|
|
141
178
|
*/
|
|
142
179
|
function computeResourceDecision(measurements, opts) {
|
|
143
180
|
const { config, plannedAgents } = opts;
|
|
144
|
-
const { ramFreeGb, ramAvailableGb, cpuLoadPct, cpuLoad5mPct, concurrentSessions } = measurements;
|
|
145
181
|
const T = config['resource-thresholds'];
|
|
146
182
|
|
|
147
|
-
// macOS fix (#667): os.freemem() reports only `Pages free`, which reads
|
|
148
|
-
// sub-1 GB even on a 128 GB host with 80+ GB reclaimable cache — a false
|
|
149
|
-
// RAM-critical that forced spurious coordinator-direct fallbacks. When the
|
|
150
|
-
// probe supplied a numeric `ramAvailableGb` (free + reclaimable, via vm_stat),
|
|
151
|
-
// judge RAM thresholds on AVAILABLE; otherwise fall back to FREE (Linux/Win,
|
|
152
|
-
// where os.freemem() is already accurate).
|
|
153
|
-
const hasAvailable = ramAvailableGb !== null && ramAvailableGb !== undefined;
|
|
154
|
-
const effectiveRamGb = hasAvailable ? ramAvailableGb : ramFreeGb;
|
|
155
|
-
const ramLabel = hasAvailable ? 'RAM available' : 'RAM free';
|
|
156
|
-
|
|
157
183
|
// Rule 3: resource-thresholds missing → degrade to proceed (defensive).
|
|
158
184
|
// Handles legacy pre-#166 configs and test fixtures that omit the key.
|
|
159
185
|
// The gate is considered "ran" — caller receives measurements but no enforcement.
|
|
@@ -166,77 +192,45 @@ function computeResourceDecision(measurements, opts) {
|
|
|
166
192
|
};
|
|
167
193
|
}
|
|
168
194
|
|
|
169
|
-
//
|
|
170
|
-
|
|
195
|
+
// Translate the gate's measurement shape into a probe-shaped snapshot so the
|
|
196
|
+
// single rule engine can judge it. Field names differ because the override
|
|
197
|
+
// object is a documented public test seam that predates the snapshot shape.
|
|
198
|
+
const snapshot = {
|
|
199
|
+
ram_free_gb: measurements.ramFreeGb,
|
|
200
|
+
ram_available_gb: measurements.ramAvailableGb ?? null,
|
|
201
|
+
memory_pressure_pct_free: measurements.memoryPressurePctFree ?? null,
|
|
202
|
+
cpu_load_pct: measurements.cpuLoadPct,
|
|
203
|
+
cpu_load_5m_pct: measurements.cpuLoad5mPct ?? null,
|
|
204
|
+
peer_sessions_count: measurements.peerSessions ?? null,
|
|
205
|
+
claude_processes_count: measurements.claudeProcesses ?? null,
|
|
206
|
+
swap_used_mb: measurements.swapUsedMb ?? null,
|
|
207
|
+
zombie_processes_count: measurements.zombieProcesses ?? null,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// heavyRepo is applied by applyHeavyRepoCap() on the way out, so it is
|
|
211
|
+
// deliberately NOT passed here — passing it would apply the ceiling twice.
|
|
212
|
+
const verdict = evaluate(snapshot, T);
|
|
213
|
+
|
|
214
|
+
if (verdict.verdict === 'critical') {
|
|
171
215
|
return {
|
|
172
216
|
decision: 'coordinator-direct',
|
|
173
217
|
agents: 0,
|
|
174
|
-
reasons:
|
|
175
|
-
`${ramLabel} ${effectiveRamGb}GB < critical ${T['ram-free-critical-gb']}GB — escalating to coordinator-direct`,
|
|
176
|
-
],
|
|
177
|
-
measurements,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Rule 5: RAM below min (but above critical) → reduce.
|
|
182
|
-
if (effectiveRamGb < T['ram-free-min-gb']) {
|
|
183
|
-
return {
|
|
184
|
-
decision: 'reduce',
|
|
185
|
-
agents: Math.max(1, Math.floor(plannedAgents / 2)),
|
|
186
|
-
reasons: [
|
|
187
|
-
`${ramLabel} ${effectiveRamGb}GB < min ${T['ram-free-min-gb']}GB — reducing agent count`,
|
|
188
|
-
],
|
|
218
|
+
reasons: verdict.reasons,
|
|
189
219
|
measurements,
|
|
190
220
|
};
|
|
191
221
|
}
|
|
192
|
-
|
|
193
|
-
// Rule 6: CPU overloaded → reduce. #943: this gate runs, by construction,
|
|
194
|
-
// right after the coordinator's own CPU-saturating quality-gate run — the 1m
|
|
195
|
-
// load average still carries that decaying tail (observed 2026-07-30:
|
|
196
|
-
// 96% → 91% → 78% → 75% within 36s), so a 1m-only reading systematically
|
|
197
|
-
// over-reports and halves waves without a real bottleneck. When the probe
|
|
198
|
-
// supplied a numeric 5m percentage, judge on min(1m, 5m): only-1m-high is a
|
|
199
|
-
// decaying transient (informational, no reduce), both-high is genuine
|
|
200
|
-
// sustained load. `cpuLoad5mPct` null (legacy overrides, Windows) → 1m-only.
|
|
201
|
-
const has5mCpu = typeof cpuLoad5mPct === 'number' && Number.isFinite(cpuLoad5mPct);
|
|
202
|
-
const effectiveCpuLoadPct = has5mCpu ? Math.min(cpuLoadPct, cpuLoad5mPct) : cpuLoadPct;
|
|
203
|
-
if (effectiveCpuLoadPct > T['cpu-load-max-pct']) {
|
|
204
|
-
const detail = has5mCpu ? ` (min of 1m ${cpuLoadPct}% / 5m ${cpuLoad5mPct}%)` : '';
|
|
222
|
+
if (verdict.verdict === 'warn') {
|
|
205
223
|
return {
|
|
206
224
|
decision: 'reduce',
|
|
207
225
|
agents: Math.max(1, Math.floor(plannedAgents / 2)),
|
|
208
|
-
reasons:
|
|
209
|
-
`CPU load ${effectiveCpuLoadPct}%${detail} > max ${T['cpu-load-max-pct']}% — reducing agent count`,
|
|
210
|
-
],
|
|
211
|
-
measurements,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
const cpuTransientNote =
|
|
215
|
-
has5mCpu && cpuLoadPct > T['cpu-load-max-pct']
|
|
216
|
-
? `info: CPU 1m load ${cpuLoadPct}% > max ${T['cpu-load-max-pct']}% but 5m load ${cpuLoad5mPct}% is below — decaying transient (typically the coordinator's own just-finished gate run), not reducing (#943)`
|
|
217
|
-
: null;
|
|
218
|
-
|
|
219
|
-
// Rule 7: concurrent sessions above warn → proceed with warning.
|
|
220
|
-
if (concurrentSessions > T['concurrent-sessions-warn']) {
|
|
221
|
-
return {
|
|
222
|
-
decision: 'proceed',
|
|
223
|
-
agents: plannedAgents,
|
|
224
|
-
reasons: [
|
|
225
|
-
...(cpuTransientNote ? [cpuTransientNote] : []),
|
|
226
|
-
`warn: ${concurrentSessions} concurrent sessions`,
|
|
227
|
-
],
|
|
226
|
+
reasons: verdict.reasons,
|
|
228
227
|
measurements,
|
|
229
228
|
};
|
|
230
229
|
}
|
|
231
|
-
|
|
232
|
-
// Rule 8: all within bounds.
|
|
233
230
|
return {
|
|
234
231
|
decision: 'proceed',
|
|
235
232
|
agents: plannedAgents,
|
|
236
|
-
reasons: [
|
|
237
|
-
...(cpuTransientNote ? [cpuTransientNote] : []),
|
|
238
|
-
'all thresholds within bounds',
|
|
239
|
-
],
|
|
233
|
+
reasons: verdict.reasons.length > 0 ? verdict.reasons : ['all thresholds within bounds'],
|
|
240
234
|
measurements,
|
|
241
235
|
};
|
|
242
236
|
}
|