session-orchestrator 3.17.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 +105 -412
- package/README.md +12 -9
- 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/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- 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/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- 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/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- 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/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/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/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- 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/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 +9 -3
- 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/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-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/validator.mjs +16 -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/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/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/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 +17 -3
- 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/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 +17 -1
- 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 +34 -13
- 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 +73 -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/validator.mjs +108 -7
- 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
|
@@ -60,7 +60,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from '
|
|
|
60
60
|
import { dirname, join } from 'node:path';
|
|
61
61
|
import { fileURLToPath } from 'node:url';
|
|
62
62
|
|
|
63
|
+
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
64
|
+
import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
|
|
63
65
|
import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
66
|
+
|
|
64
67
|
export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
65
68
|
|
|
66
69
|
// ---------------------------------------------------------------------------
|
|
@@ -361,6 +364,44 @@ export function detectSharedLibTouch(opts) {
|
|
|
361
364
|
return { touched: matched.length > 0, paths: matched };
|
|
362
365
|
}
|
|
363
366
|
|
|
367
|
+
/**
|
|
368
|
+
* INPUT ADAPTER for this module's `counts` field (#954, #967 item 2).
|
|
369
|
+
*
|
|
370
|
+
* Holds NO admission policy of its own. Every verdict — including both
|
|
371
|
+
* rejections this function used to make itself — is delegated to
|
|
372
|
+
* {@link admitSuiteCounts}, the single policy shared with the CLI producer
|
|
373
|
+
* `suiteCountsFromGateStdout` (`scripts/run-quality-gate.mjs`). Before that
|
|
374
|
+
* convergence the two producers wrote the SAME event field under DIFFERENT
|
|
375
|
+
* rules (this one admitted `passed > total` and a negative `passed`), so a
|
|
376
|
+
* consumer had to know two policies to read one field.
|
|
377
|
+
*
|
|
378
|
+
* What stays here is the part the shared policy cannot see: the raw-text tail
|
|
379
|
+
* parse. `extractTestCounts` has no "did it match?" channel — it returns
|
|
380
|
+
* `0/0/0` both for "no `<N> passed` marker in the output" and for a genuinely
|
|
381
|
+
* empty run — so a text-less input is handed to the policy as `null` rather
|
|
382
|
+
* than as a zero triple, and the policy refuses it. The result is `null`, never
|
|
383
|
+
* a zero triple: `counts.failed === 0` means "measured, zero failures"; an
|
|
384
|
+
* ABSENT `counts` means "not measured".
|
|
385
|
+
*
|
|
386
|
+
* That null hand-over is ALSO how this adapter reports "the gate loop never
|
|
387
|
+
* reached the test step" (#969 MED-2). A `null` output fails the string check on
|
|
388
|
+
* the first line, so the positional evidence and the unparseable-text case land
|
|
389
|
+
* on the SAME channel the policy already has to check. The policy previously
|
|
390
|
+
* took a second `measured` boolean for the positional case; it was unreachable
|
|
391
|
+
* with a non-null triple precisely because this line runs first, and two ways to
|
|
392
|
+
* say "not measured" is one more than the field can be read with.
|
|
393
|
+
*
|
|
394
|
+
* @param {string|null} output — captured stdout+stderr tail from the test gate,
|
|
395
|
+
* or `null` when the gate loop never reached the test step.
|
|
396
|
+
* @returns {{ passed: number, failed: number, total: number }|null}
|
|
397
|
+
*/
|
|
398
|
+
function suiteCountsFromOutput(output) {
|
|
399
|
+
if (typeof output !== 'string' || output.length === 0) {
|
|
400
|
+
return admitSuiteCounts(null);
|
|
401
|
+
}
|
|
402
|
+
return admitSuiteCounts(extractTestCounts(output));
|
|
403
|
+
}
|
|
404
|
+
|
|
364
405
|
/**
|
|
365
406
|
* Coerce `maxRetries` to [0, MAX_RETRIES_HARD_CAP] integer.
|
|
366
407
|
*
|
|
@@ -379,6 +420,97 @@ function coerceMaxRetries(n) {
|
|
|
379
420
|
// Public API
|
|
380
421
|
// ---------------------------------------------------------------------------
|
|
381
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Emit exactly one `orchestrator.quality_gate.{passed,failed}` event per
|
|
425
|
+
* `runQualityGateWithRetry` CALL (#928b).
|
|
426
|
+
*
|
|
427
|
+
* NO DOUBLE-COUNTING. The two gate paths never nest:
|
|
428
|
+
* - `scripts/run-quality-gate.mjs` spawns `scripts/lib/gates/gate-*.mjs`
|
|
429
|
+
* directly and does not import this module;
|
|
430
|
+
* - this module spawns the resolved gate COMMANDS (and `parse-config.mjs`)
|
|
431
|
+
* directly and does not invoke that wrapper.
|
|
432
|
+
* A single run therefore passes through exactly one emitter.
|
|
433
|
+
*
|
|
434
|
+
* Granularity is the CALL, not the attempt: the retry loop may run the gates
|
|
435
|
+
* up to `maxRetries + 1` times, but emitting per attempt would inflate every
|
|
436
|
+
* rate computed over these events. `attempts` carries that detail instead.
|
|
437
|
+
* `variant: 'auto-fix-loop'` distinguishes this path from the CLI's
|
|
438
|
+
* baseline/incremental/full-gate/per-file variants.
|
|
439
|
+
*
|
|
440
|
+
* The destination is pinned to the `repoRoot` this gate actually ran against,
|
|
441
|
+
* via the `opts.repoRoot` parameter (#941; formerly a hand-built `opts.filePath`
|
|
442
|
+
* recipe, #611). `emitEvent`'s BARE default resolution goes through the
|
|
443
|
+
* module-level `SO_PROJECT_DIR` constant, which ignores `repoRoot` entirely — so
|
|
444
|
+
* a caller running the gate against another tree (every unit test does, using a
|
|
445
|
+
* tmp `repoRoot`) would otherwise append synthetic records to the REAL repo's
|
|
446
|
+
* telemetry. That is not a test-hygiene nicety: injected `quality_gate.failed`
|
|
447
|
+
* records are exactly what `/eval`'s gate-health dimension reads, so the
|
|
448
|
+
* instrument would be scored against its own test fixtures.
|
|
449
|
+
*
|
|
450
|
+
* `counts` (#954) carries the suite numbers this gate already had in hand. It
|
|
451
|
+
* is OMITTED — never zero-filled — whenever the final attempt did not reach the
|
|
452
|
+
* test gate (fail-fast on lint/typecheck) or its output carried no parseable
|
|
453
|
+
* count. See {@link suiteCountsFromOutput}.
|
|
454
|
+
*
|
|
455
|
+
* IT DOES NOT YET REPLACE THE PROSE PATH (#957/F1 — the earlier wording here
|
|
456
|
+
* claimed it did). `waves[].suite_passed` / `suite_failed` still travel as prose
|
|
457
|
+
* through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
|
|
458
|
+
* them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
|
|
459
|
+
* the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
|
|
460
|
+
* machine-measured emission of the same fact, and as of 2026-07-31 it has zero
|
|
461
|
+
* readers (`grep -c '"counts"' .orchestrator/metrics/events.jsonl` → 0 across
|
|
462
|
+
* 4404 `orchestrator.quality_gate.*` records).
|
|
463
|
+
*
|
|
464
|
+
* Retiring the prose path needs a producer change no docblock can make:
|
|
465
|
+
* `waves[].*` is PER-WAVE, and gate events carry no `wave_number` (0 of those
|
|
466
|
+
* 4404 records). A session-end reader could only attribute a gate event to a
|
|
467
|
+
* wave by a wall-clock window whose own boundaries (`waves[].started_at` /
|
|
468
|
+
* `completed_at`) are themselves LLM-written — one LLM hop traded for another,
|
|
469
|
+
* against the posture `scripts/lib/eval/session-resolve.mjs` already documents
|
|
470
|
+
* for window-attributed gate events ("a contaminated window means
|
|
471
|
+
* gate-attribution is unsafe"). The concrete remaining work is named in
|
|
472
|
+
* `skills/session-end/metrics-collection.md` § 1.7.
|
|
473
|
+
*
|
|
474
|
+
* Note also that THIS emitter only runs under `verification-auto-fix.enabled:
|
|
475
|
+
* true` (default `false`, and `false` in this repo's Session Config). The gate
|
|
476
|
+
* that actually fires between waves is the `scripts/run-quality-gate.mjs`
|
|
477
|
+
* wrapper, which emits its own `counts` via `suiteCountsFromGateStdout` — under
|
|
478
|
+
* the SAME admission policy since #967 item 2 (`admitSuiteCounts`), so a
|
|
479
|
+
* consumer reads one field with one set of rules regardless of which producer
|
|
480
|
+
* wrote it.
|
|
481
|
+
*
|
|
482
|
+
* Extraction margin: {@link suiteCountsFromOutput} sees only the
|
|
483
|
+
* `OUTPUT_TAIL_LINES` (50) tail `runCheck` retains. Measured on `npm test`
|
|
484
|
+
* (vitest 2026-07-31), the `Tests` summary line sits 5 lines from the end — 45
|
|
485
|
+
* lines of headroom. A runner epilogue longer than that (coverage table, long
|
|
486
|
+
* unhandled-error dump) pushes the summary out of the window; `counts` is then
|
|
487
|
+
* omitted, which fails safe but is indistinguishable from "no test gate ran".
|
|
488
|
+
*
|
|
489
|
+
* Best-effort: never throws, never alters the gate verdict.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} repoRoot
|
|
492
|
+
* @param {boolean} ok
|
|
493
|
+
* @param {number} attempts
|
|
494
|
+
* @param {string|null} gate
|
|
495
|
+
* @param {{passed: number, failed: number, total: number}|null} [counts]
|
|
496
|
+
*/
|
|
497
|
+
async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
|
|
498
|
+
try {
|
|
499
|
+
await emitEvent(
|
|
500
|
+
`orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
|
|
501
|
+
{
|
|
502
|
+
variant: 'auto-fix-loop',
|
|
503
|
+
exit_code: ok ? 0 : 1,
|
|
504
|
+
attempts,
|
|
505
|
+
...(gate ? { gate } : {}),
|
|
506
|
+
...(counts ? { counts } : {}),
|
|
507
|
+
...sessionAttribution(repoRoot),
|
|
508
|
+
},
|
|
509
|
+
{ repoRoot },
|
|
510
|
+
);
|
|
511
|
+
} catch { /* best-effort telemetry — gate result is authoritative */ }
|
|
512
|
+
}
|
|
513
|
+
|
|
382
514
|
/**
|
|
383
515
|
* Run quality gate (lint → typecheck → test, fail-fast), dispatching a fixer
|
|
384
516
|
* callback on each failure up to `maxRetries` times.
|
|
@@ -421,6 +553,14 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
421
553
|
const allFailures = [];
|
|
422
554
|
let attempt = 0;
|
|
423
555
|
let lastFailure = null;
|
|
556
|
+
/**
|
|
557
|
+
* Suite counts observed in the CURRENT attempt only (#954). Re-assigned
|
|
558
|
+
* unconditionally after every attempt's gate loop, so a failure that
|
|
559
|
+
* fail-fasts on lint can never re-report a stale count from an earlier
|
|
560
|
+
* attempt's test run.
|
|
561
|
+
* @type {{passed: number, failed: number, total: number}|null}
|
|
562
|
+
*/
|
|
563
|
+
let testCounts = null;
|
|
424
564
|
|
|
425
565
|
// Total loop budget = maxRetries + 1 (one initial run + up to maxRetries fixer-driven retries).
|
|
426
566
|
const totalAttempts = maxRetries + 1;
|
|
@@ -428,10 +568,30 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
428
568
|
while (attempt < totalAttempts) {
|
|
429
569
|
attempt += 1;
|
|
430
570
|
let gateFailure = null;
|
|
571
|
+
/**
|
|
572
|
+
* The test gate's captured output for THIS attempt — `null` until the
|
|
573
|
+
* `test` branch below assigns it, which happens only if the gate loop
|
|
574
|
+
* actually reaches the test step. A fail-fast on lint or typecheck leaves
|
|
575
|
+
* it `null`.
|
|
576
|
+
*
|
|
577
|
+
* That null-ness is the positional evidence {@link admitSuiteCounts}
|
|
578
|
+
* cannot observe for itself: the shared policy sees a candidate triple and
|
|
579
|
+
* nothing else, never the control flow that produced it. It reaches the
|
|
580
|
+
* policy AS the null triple {@link suiteCountsFromOutput} hands over, so an
|
|
581
|
+
* unmeasured gate can never publish a zero triple attributed to a run that
|
|
582
|
+
* never happened.
|
|
583
|
+
* @type {string|null}
|
|
584
|
+
*/
|
|
585
|
+
let testOutput = null;
|
|
431
586
|
|
|
432
587
|
for (const gate of GATE_ORDER) {
|
|
433
588
|
const cmd = commands[gate];
|
|
434
589
|
const result = runGate(cmd, repoRoot);
|
|
590
|
+
if (gate === 'test') {
|
|
591
|
+
// The numbers are in hand right here — capture them at the seam rather
|
|
592
|
+
// than letting them travel as prose (#954).
|
|
593
|
+
testOutput = result.output;
|
|
594
|
+
}
|
|
435
595
|
if (result.exitCode === 0) {
|
|
436
596
|
process.stderr.write(`🔁 quality-gate attempt ${attempt}/${totalAttempts} (gate=${gate}): pass\n`);
|
|
437
597
|
continue;
|
|
@@ -449,9 +609,14 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
449
609
|
break;
|
|
450
610
|
}
|
|
451
611
|
|
|
612
|
+
// Non-fatal by construction: an unmeasured, unparseable or inconsistent
|
|
613
|
+
// run yields null, which the emitter omits rather than zero-fills.
|
|
614
|
+
testCounts = suiteCountsFromOutput(testOutput);
|
|
615
|
+
|
|
452
616
|
if (gateFailure === null) {
|
|
453
617
|
// All gates passed this attempt.
|
|
454
618
|
writeLastGreenSha(repoRoot);
|
|
619
|
+
await emitGateEvent(repoRoot, true, attempt, null, testCounts);
|
|
455
620
|
return { ok: true, attempts: attempt };
|
|
456
621
|
}
|
|
457
622
|
|
|
@@ -516,6 +681,8 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
516
681
|
`❌ quality-gate exhausted retries (${attempt}), writing diagnostics to ${bundlePath ?? '<unwritable>'}\n`,
|
|
517
682
|
);
|
|
518
683
|
|
|
684
|
+
await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts);
|
|
685
|
+
|
|
519
686
|
const out = {
|
|
520
687
|
ok: false,
|
|
521
688
|
attempts: attempt,
|
|
@@ -26,7 +26,7 @@ const VALID_MODES = new Set([
|
|
|
26
26
|
* @param {number} input.completionRate — 0.00..1.00; share of planned issues closed
|
|
27
27
|
* @param {number} input.carryoverRatio — 0.00..1.00; carryover / planned (0 when planned=0)
|
|
28
28
|
* @param {Array<number|string>} [input.carryoverIssues] — IDs/IIDs of carried-over issues,
|
|
29
|
-
* pre-sorted (priority
|
|
29
|
+
* pre-sorted (priority::critical/high first, FIFO tiebreak). Sliced to 0-5 entries for
|
|
30
30
|
* the `priorities` field.
|
|
31
31
|
* @returns {{mode: string, priorities: number[], rationale: string}}
|
|
32
32
|
*/
|
|
@@ -135,7 +135,7 @@ export function classifyLearning(learning, { now, minInsightChars } = {}) {
|
|
|
135
135
|
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
|
136
136
|
return {
|
|
137
137
|
eligible: false,
|
|
138
|
-
reason: `eligible type '${type}' but empty file_paths[] — cannot scope a conditional rule`,
|
|
138
|
+
reason: `eligible type '${type}' but empty file_paths[] — cannot scope a conditional rule (propose with --file-paths to make this convertible)`,
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -65,13 +65,26 @@ function kebab(s) {
|
|
|
65
65
|
.replace(/^-+|-+$/g, '');
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
|
|
69
|
+
// (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
|
|
70
|
+
// stray '**' or '[ab]' entry (e.g. from an OLD learning record predating the
|
|
71
|
+
// #900 C / schema.mjs argv-boundary guards) must never reach the renderer
|
|
72
|
+
// verbatim, or it would produce an effectively always-on rule glob. Skipped
|
|
73
|
+
// here as defense-in-depth even though the argv (memory-propose.mjs) and
|
|
74
|
+
// schema (memory-proposals/schema.mjs) layers already reject these at write
|
|
75
|
+
// time — this emitter also processes learnings.jsonl entries that predate
|
|
76
|
+
// those guards.
|
|
77
|
+
const GLOB_METACHAR_RE = /[*?[\]{}]/;
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* Derive non-empty directory globs from a learning's `file_paths`.
|
|
70
81
|
*
|
|
71
82
|
* For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
|
|
72
83
|
* the file sits at the repo top level (`dirname` === '.'), emit the bare
|
|
73
|
-
* basename pattern instead of `./**` (e.g. `"foo.mjs"`).
|
|
74
|
-
*
|
|
84
|
+
* basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
|
|
85
|
+
* glob metacharacter (`* ? [ ] { }`) are skipped entirely — see
|
|
86
|
+
* {@link GLOB_METACHAR_RE}. Results are deduped, order-preserving on first
|
|
87
|
+
* occurrence.
|
|
75
88
|
*
|
|
76
89
|
* @param {string[]} filePaths
|
|
77
90
|
* @returns {string[]}
|
|
@@ -81,6 +94,7 @@ function globsFromFilePaths(filePaths) {
|
|
|
81
94
|
const seen = new Set();
|
|
82
95
|
for (const raw of filePaths) {
|
|
83
96
|
if (typeof raw !== 'string' || raw === '') continue;
|
|
97
|
+
if (GLOB_METACHAR_RE.test(raw)) continue;
|
|
84
98
|
const normalized = raw.replace(/\\/g, '/');
|
|
85
99
|
const dir = dirname(normalized);
|
|
86
100
|
const pattern = dir === '.' ? normalized : `${dir}/**`;
|
|
@@ -181,8 +195,13 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
|
|
|
181
195
|
* @param {object} [opts]
|
|
182
196
|
* @param {number} [opts.ruleExpiryDays] - explicit expiry window in days; when a
|
|
183
197
|
* finite number, overrides the per-type TTL.
|
|
184
|
-
* @param {number|Date} [opts.now] - injectable clock
|
|
185
|
-
*
|
|
198
|
+
* @param {number|Date} [opts.now] - injectable clock (defaults to `Date.now()`).
|
|
199
|
+
* Used for BOTH the born-dead expiry floor (`now + minRuleDays`, applied on
|
|
200
|
+
* every call — see {@link computeExpiresAt}) and, secondarily, as the base
|
|
201
|
+
* date when `created_at` is missing/unparseable. Because the floor consults
|
|
202
|
+
* it unconditionally, any test asserting a literal `expiresAt` MUST inject
|
|
203
|
+
* `now` — otherwise the wall clock eventually raises the floor past the
|
|
204
|
+
* derived expiry and the assertion silently starts measuring the floor.
|
|
186
205
|
* @param {number} [opts.minRuleDays] - floor window (days) applied to the
|
|
187
206
|
* emitted expiry so it never falls in the past — see
|
|
188
207
|
* {@link computeExpiresAt} and {@link MIN_RULE_DAYS_DEFAULT}. Defaults
|
|
@@ -62,7 +62,24 @@
|
|
|
62
62
|
* @property {number} eligible
|
|
63
63
|
* @property {number} proposed
|
|
64
64
|
* @property {number} rejected
|
|
65
|
+
* @property {number} capped - count of eligible learnings that were NOT proposed
|
|
66
|
+
* this run purely because of the `maxProposalsPerRun` volume brake (issue
|
|
67
|
+
* #900 D — confidence-sorted, lowest-confidence entries cut first). Each
|
|
68
|
+
* capped learning is ALSO counted inside `rejected` (with a `capped — ...`
|
|
69
|
+
* reason) — `totalLearnings === proposed + rejected` still holds unchanged;
|
|
70
|
+
* `capped` is a diagnostic sub-count that lets a report distinguish
|
|
71
|
+
* "genuinely ineligible" rejections from "eligible but cut by the volume
|
|
72
|
+
* brake" ones at a glance.
|
|
65
73
|
* @property {boolean} written
|
|
74
|
+
* @property {number} [skipped] - how many persisted sidecar lines the store's
|
|
75
|
+
* read-side shape guard rejected and this run therefore DROPPED from disk
|
|
76
|
+
* (`mergeCandidates` rewrites the store in full, never appends — see
|
|
77
|
+
* `idempotency.mjs`). PRESENT-vs-ABSENT is load-bearing and must not be
|
|
78
|
+
* collapsed: `0` means "the store was inspected and nothing was dropped",
|
|
79
|
+
* while ABSENCE means "the store was never inspected this run" — the case
|
|
80
|
+
* under `dryRun` (merge skipped entirely), on the empty short-circuit, on
|
|
81
|
+
* the top-level error path, and when the merge seam reports no count.
|
|
82
|
+
* Defaulting the absent case to `0` would be a false all-clear.
|
|
66
83
|
*
|
|
67
84
|
* @typedef {Object} ReconcileResult
|
|
68
85
|
* @property {ReconcileProposal[]} proposals
|
|
@@ -78,11 +95,24 @@ import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mj
|
|
|
78
95
|
import { filterEligible } from './eligibility.mjs';
|
|
79
96
|
import { toActivationMetadata } from './emitter.mjs';
|
|
80
97
|
import { renderRule } from './renderer.mjs';
|
|
81
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
DEFAULT_STORE_PATH,
|
|
100
|
+
buildCandidate,
|
|
101
|
+
makeCandidateId,
|
|
102
|
+
mergeCandidates as realMergeCandidates,
|
|
103
|
+
} from './idempotency.mjs';
|
|
82
104
|
|
|
83
105
|
/** Default repo-relative location of the learnings corpus. */
|
|
84
106
|
const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
|
|
85
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Default volume brake (issue #900 D) — mirrors the `reconcile.max-proposals-
|
|
110
|
+
* per-run` Session Config default in `scripts/lib/config/reconcile.mjs`. Applied
|
|
111
|
+
* even when a caller omits `maxProposalsPerRun` entirely, so the engine never
|
|
112
|
+
* silently proposes an unbounded number of rules in one run.
|
|
113
|
+
*/
|
|
114
|
+
const DEFAULT_MAX_PROPOSALS_PER_RUN = 10;
|
|
115
|
+
|
|
86
116
|
/**
|
|
87
117
|
* Build a fully-zeroed result (the empty / error shape). Touches no disk.
|
|
88
118
|
* @param {string} [error]
|
|
@@ -98,6 +128,7 @@ function zeroedResult(error) {
|
|
|
98
128
|
eligible: 0,
|
|
99
129
|
proposed: 0,
|
|
100
130
|
rejected: 0,
|
|
131
|
+
capped: 0,
|
|
101
132
|
written: false,
|
|
102
133
|
},
|
|
103
134
|
};
|
|
@@ -186,33 +217,31 @@ function learningType(learning) {
|
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
220
|
+
* Surface a shape-guard drop on stderr. The sidecar is a mutable work-queue that
|
|
221
|
+
* `mergeCandidates` rewrites in FULL, so a record the read-side shape guard
|
|
222
|
+
* rejects is not merely ignored — it is gone from disk after this run. The count
|
|
223
|
+
* alone makes that loss attributable; this WARN is what makes it VISIBLE, since
|
|
224
|
+
* the summary field only helps a caller that thinks to read it.
|
|
225
|
+
*
|
|
226
|
+
* Never throws: a failing diagnostic must not break the never-throws contract of
|
|
227
|
+
* {@link runReconcile} (a broken stderr pipe would otherwise zero the result).
|
|
192
228
|
*
|
|
193
|
-
* @param {
|
|
194
|
-
* @
|
|
195
|
-
* @param {string|null} params.learningKey
|
|
196
|
-
* @param {string} params.slug
|
|
197
|
-
* @param {'proposed'|'rejected'} params.status
|
|
198
|
-
* @param {string} params.reason
|
|
199
|
-
* @param {number} params.confidence
|
|
200
|
-
* @param {string} params.createdAt - ISO timestamp.
|
|
201
|
-
* @returns {import('./idempotency.mjs').ReconcileCandidate}
|
|
229
|
+
* @param {number} skipped - drop count (> 0 by the time this is called).
|
|
230
|
+
* @returns {void}
|
|
202
231
|
*/
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
232
|
+
function warnDroppedStoreRecords(skipped) {
|
|
233
|
+
try {
|
|
234
|
+
console.warn(
|
|
235
|
+
`⚠️ reconcile: ${skipped} record(s) in ${DEFAULT_STORE_PATH} failed the ` +
|
|
236
|
+
`candidate shape guard and were DROPPED by this merge — the store is ` +
|
|
237
|
+
`rewritten in full, so they are no longer on disk. Expected shape: a ` +
|
|
238
|
+
`ReconcileCandidate with \`learning_key\` + \`created_at\` (see ` +
|
|
239
|
+
`scripts/lib/reconcile/idempotency.mjs). Only \`mergeCandidates\` may ` +
|
|
240
|
+
`write this store; hand-written or report records do not belong in it.`,
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
// A diagnostic must never become the failure it reports on.
|
|
244
|
+
}
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
/**
|
|
@@ -236,6 +265,11 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
|
|
|
236
265
|
* eligibility placeholder-insight check (forwarded to `filterEligible`). Inert
|
|
237
266
|
* (no additional rejections) when omitted.
|
|
238
267
|
* @param {number|Date} [params.now] - injectable clock (emitter fallback + candidate `created_at`).
|
|
268
|
+
* @param {number} [params.maxProposalsPerRun] - volume brake (issue #900 D): after
|
|
269
|
+
* sorting eligible learnings by confidence DESC, only the top N are proposed;
|
|
270
|
+
* the rest are recorded as `capped` rejections. Defaults to
|
|
271
|
+
* {@link DEFAULT_MAX_PROPOSALS_PER_RUN} (10) when omitted, non-finite, or < 1
|
|
272
|
+
* — the brake is ALWAYS active, matching the Session Config default.
|
|
239
273
|
* @param {boolean} [params.dryRun] - when true, compute proposals but SKIP the merge entirely
|
|
240
274
|
* (also accepted as `opts.dryRun`; either location sets it).
|
|
241
275
|
* @param {Object} [opts] - DI seams (all default to real behaviour).
|
|
@@ -249,7 +283,15 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
|
|
|
249
283
|
* @returns {Promise<ReconcileResult>}
|
|
250
284
|
*/
|
|
251
285
|
export async function runReconcile(
|
|
252
|
-
{
|
|
286
|
+
{
|
|
287
|
+
repoRoot,
|
|
288
|
+
ruleExpiryDays,
|
|
289
|
+
minRuleDays,
|
|
290
|
+
minInsightChars,
|
|
291
|
+
now,
|
|
292
|
+
maxProposalsPerRun: maxProposalsPerRunParam,
|
|
293
|
+
dryRun: dryRunParam,
|
|
294
|
+
} = {},
|
|
253
295
|
opts = {},
|
|
254
296
|
) {
|
|
255
297
|
try {
|
|
@@ -258,6 +300,12 @@ export async function runReconcile(
|
|
|
258
300
|
// (the documented DI seam) — either location flips it on.
|
|
259
301
|
const dryRun = dryRunParam === true || opts.dryRun === true;
|
|
260
302
|
const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
|
|
303
|
+
// Volume brake (#900 D) — always active; a missing/invalid override falls
|
|
304
|
+
// back to the same default the Session Config parser uses.
|
|
305
|
+
const maxProposalsPerRun =
|
|
306
|
+
Number.isFinite(maxProposalsPerRunParam) && maxProposalsPerRunParam >= 1
|
|
307
|
+
? Math.floor(maxProposalsPerRunParam)
|
|
308
|
+
: DEFAULT_MAX_PROPOSALS_PER_RUN;
|
|
261
309
|
|
|
262
310
|
// --- Pipeline step 1 — load learnings ----------------------------------
|
|
263
311
|
/** @type {Array<Record<string, unknown>>} */
|
|
@@ -289,6 +337,21 @@ export async function runReconcile(
|
|
|
289
337
|
minInsightChars,
|
|
290
338
|
});
|
|
291
339
|
|
|
340
|
+
// --- Pipeline step 3b — volume brake (#900 D) ---------------------------
|
|
341
|
+
// Sort eligible learnings by confidence DESC (ties keep their original,
|
|
342
|
+
// stable relative order) and keep only the top `maxProposalsPerRun`. The
|
|
343
|
+
// rest are cut BEFORE they ever reach the emitter — never proposed this
|
|
344
|
+
// run — and recorded as `capped` rejections in step 4b below so a report
|
|
345
|
+
// stays honest about the cut instead of silently dropping them.
|
|
346
|
+
const confidenceOf = (l) =>
|
|
347
|
+
l && typeof l === 'object' && typeof l.confidence === 'number' ? l.confidence : 0;
|
|
348
|
+
const sortedEligible = eligible
|
|
349
|
+
.map((learning, index) => ({ learning, index }))
|
|
350
|
+
.sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
|
|
351
|
+
.map(({ learning }) => learning);
|
|
352
|
+
const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
|
|
353
|
+
const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
|
|
354
|
+
|
|
292
355
|
/** @type {ReconcileProposal[]} */
|
|
293
356
|
const proposals = [];
|
|
294
357
|
/** @type {ReconcileRejection[]} */
|
|
@@ -297,7 +360,7 @@ export async function runReconcile(
|
|
|
297
360
|
const candidates = [];
|
|
298
361
|
|
|
299
362
|
// --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
|
|
300
|
-
for (const learning of
|
|
363
|
+
for (const learning of keptEligible) {
|
|
301
364
|
try {
|
|
302
365
|
const metadata = toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays });
|
|
303
366
|
const { slug, path, content } = renderRule(learning, metadata);
|
|
@@ -352,6 +415,34 @@ export async function runReconcile(
|
|
|
352
415
|
}
|
|
353
416
|
}
|
|
354
417
|
|
|
418
|
+
// --- Pipeline step 4b — capped-eligible learnings (#900 D) --------------
|
|
419
|
+
// Learnings that passed eligibility but were cut by the volume brake are
|
|
420
|
+
// NEVER passed to the emitter — recorded directly as rejections (with a
|
|
421
|
+
// `capped — ...` reason) so a report distinguishes this from a genuine
|
|
422
|
+
// ineligibility rejection.
|
|
423
|
+
for (const learning of cappedEligible) {
|
|
424
|
+
const learningKey = rejectedLearningKey(learning);
|
|
425
|
+
const type = learningType(learning);
|
|
426
|
+
const reason = `capped — max-proposals-per-run (${maxProposalsPerRun}) reached; ${cappedEligible.length} lower-confidence eligible learning(s) not proposed this run`;
|
|
427
|
+
rejected.push({
|
|
428
|
+
learningKey,
|
|
429
|
+
type,
|
|
430
|
+
reason,
|
|
431
|
+
status: 'rejected',
|
|
432
|
+
});
|
|
433
|
+
candidates.push(
|
|
434
|
+
buildCandidate({
|
|
435
|
+
id: makeCandidateId(learningKey ?? '', `rejected-${type}`),
|
|
436
|
+
learningKey,
|
|
437
|
+
slug: '',
|
|
438
|
+
status: 'rejected',
|
|
439
|
+
reason,
|
|
440
|
+
confidence: confidenceOf(learning),
|
|
441
|
+
createdAt,
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
355
446
|
// --- Pipeline step 5 — per rejected learning (eligibility rejects) ------
|
|
356
447
|
for (const { learning, reason } of rejectedLearnings) {
|
|
357
448
|
const learningKey = rejectedLearningKey(learning);
|
|
@@ -382,28 +473,45 @@ export async function runReconcile(
|
|
|
382
473
|
// The engine's ONLY disk write — and it is skipped entirely under dryRun.
|
|
383
474
|
// It never writes `.claude/rules/` (FA3 / #696 owns that, post-approval).
|
|
384
475
|
let written = false;
|
|
476
|
+
// `undefined` (NOT 0) until the merge actually inspects the store — absence
|
|
477
|
+
// means "not checked", 0 means "checked, nothing dropped". See the
|
|
478
|
+
// ReconcileSummary `skipped` typedef.
|
|
479
|
+
/** @type {number|undefined} */
|
|
480
|
+
let skipped;
|
|
385
481
|
if (!dryRun) {
|
|
386
482
|
try {
|
|
387
483
|
const mergeResult = merge({ candidates, repoRoot });
|
|
388
484
|
written = !!(mergeResult && mergeResult.written === true);
|
|
485
|
+
// Only a finite count from the seam counts as "inspected". A merge seam
|
|
486
|
+
// that reports nothing leaves `skipped` absent rather than fabricating 0.
|
|
487
|
+
if (mergeResult && Number.isFinite(mergeResult.skipped)) {
|
|
488
|
+
skipped = Number(mergeResult.skipped);
|
|
489
|
+
}
|
|
389
490
|
} catch {
|
|
390
|
-
// Merge failure is non-fatal; proposals still returned, written stays
|
|
491
|
+
// Merge failure is non-fatal; proposals still returned, written stays
|
|
492
|
+
// false and `skipped` stays absent (the store was never inspected).
|
|
391
493
|
written = false;
|
|
392
494
|
}
|
|
393
495
|
}
|
|
394
496
|
|
|
497
|
+
// Make an attributable drop VISIBLE, not merely recorded (WARN, never throw).
|
|
498
|
+
if (typeof skipped === 'number' && skipped > 0) warnDroppedStoreRecords(skipped);
|
|
499
|
+
|
|
395
500
|
// --- Pipeline step 7 — summary -----------------------------------------
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
written,
|
|
405
|
-
},
|
|
501
|
+
/** @type {ReconcileSummary} */
|
|
502
|
+
const summary = {
|
|
503
|
+
totalLearnings,
|
|
504
|
+
eligible: eligible.length,
|
|
505
|
+
proposed: proposals.length,
|
|
506
|
+
rejected: rejected.length,
|
|
507
|
+
capped: cappedEligible.length,
|
|
508
|
+
written,
|
|
406
509
|
};
|
|
510
|
+
// Additive + absence-preserving: the key exists ONLY when the store was
|
|
511
|
+
// actually inspected, so no consumer can read a false `skipped: 0`.
|
|
512
|
+
if (typeof skipped === 'number') summary.skipped = skipped;
|
|
513
|
+
|
|
514
|
+
return { proposals, rejected, summary };
|
|
407
515
|
} catch (err) {
|
|
408
516
|
// never-throws top-level guard.
|
|
409
517
|
const msg = err && err.message ? err.message : String(err);
|