session-orchestrator 3.17.0 → 3.20.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 +185 -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/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- 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 +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -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 +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- 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 +30 -4
- 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 +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -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/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- 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 +1273 -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/hardening.mjs +9 -9
- 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/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- 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 +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- 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 +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- 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-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- 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/soul-resolve.mjs +12 -0
- 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/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- 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/check-unwired-features.mjs +549 -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 +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -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/brainstorm/soul.md +47 -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 +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- 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/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- 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 +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- 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 +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import { dirname } from 'node:path';
|
|
32
32
|
|
|
33
|
+
import { learningKeyOf } from '../learnings/kebab.mjs';
|
|
33
34
|
import { deriveExpiresAt } from '../learnings/schema.mjs';
|
|
34
35
|
|
|
35
36
|
const DAY_MS = 86400 * 1000;
|
|
@@ -44,34 +45,88 @@ const DESCRIPTION_MAX = 120;
|
|
|
44
45
|
// shortens a healthy future one.
|
|
45
46
|
const MIN_RULE_DAYS_DEFAULT = 7;
|
|
46
47
|
|
|
48
|
+
// ── #1015: field-shape gates (VALIDATION, never transformation) ──────────────
|
|
49
|
+
// This emitter REJECTS agent-authored values whose SHAPE cannot be serialised
|
|
50
|
+
// into frontmatter safely. It deliberately does NOT escape or neutralise
|
|
51
|
+
// content: neutralisation of agent text happens exactly once, in the renderer's
|
|
52
|
+
// render path. Escaping here as well would double-escape the output — a defect
|
|
53
|
+
// invisible to a file-level diff, because each file still looks locally correct.
|
|
54
|
+
// Rejection is idempotent, so it composes safely with the renderer's sanitiser.
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Accepted shape for `learning.host_class`.
|
|
58
|
+
*
|
|
59
|
+
* `host-class` is by far the highest-escalation field this emitter copies: the
|
|
60
|
+
* renderer serialises it UNQUOTED as `host-class: <value>`, so a newline in it
|
|
61
|
+
* injects sibling TOP-LEVEL frontmatter keys. An injected `alwaysApply: true`
|
|
62
|
+
* plus `expires-at: 2099-01-01` converts a scoped, expiring auto-generated rule
|
|
63
|
+
* into a permanent always-on one — defeating BOTH the never-always-on
|
|
64
|
+
* brandmauer below AND the expiry sweep, because each of those inspects the
|
|
65
|
+
* emitter's in-memory values rather than the serialised document.
|
|
66
|
+
*
|
|
67
|
+
* `validateLearning` (scripts/lib/learnings/schema.mjs) only asserts "string or
|
|
68
|
+
* null" for this field — ANY string, newlines included — and
|
|
69
|
+
* `validateProposalRecord` performs no unknown-key rejection, so every key on a
|
|
70
|
+
* proposal record is spread verbatim into the learning
|
|
71
|
+
* (scripts/lib/memory-proposals/sink.mjs). That is the mechanism that makes an
|
|
72
|
+
* attacker-shaped `host_class` reachable at all; this gate is where it stops.
|
|
73
|
+
*
|
|
74
|
+
* The pattern admits every value `classifyHost()` in scripts/lib/host-identity.mjs
|
|
75
|
+
* can produce (`macos-arm64-m4pro`, `linux-x86_64`, `windows-arm64`,
|
|
76
|
+
* `<osName>-<arch>`) with generous headroom, and rejects everything that could
|
|
77
|
+
* alter frontmatter structure: control chars, whitespace, `:`, `#`, quotes, and
|
|
78
|
+
* a leading `-` (a YAML sequence marker). The 64-char ceiling bounds the emitted
|
|
79
|
+
* line; the longest real value today is 17 chars, so the ceiling is inert in
|
|
80
|
+
* practice. Revisit if a future host taxonomy needs richer tokens.
|
|
81
|
+
*/
|
|
82
|
+
const HOST_CLASS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
83
|
+
|
|
84
|
+
// Characters that make a `file_paths[]` entry unsafe to serialise as a glob.
|
|
85
|
+
// The renderer emits each glob as ` - "<value>"`, so a control char (a newline
|
|
86
|
+
// above all) breaks out of the block sequence into new top-level frontmatter
|
|
87
|
+
// keys, and a quote character breaks out of the quoted scalar. Entries carrying
|
|
88
|
+
// either are SKIPPED — the same defense-in-depth posture as the
|
|
89
|
+
// glob-metacharacter guard below, and for the same reason: this emitter also
|
|
90
|
+
// processes learnings.jsonl records that predate the #900 argv/schema guards.
|
|
91
|
+
const UNSAFE_PATH_QUOTE_RE = /["']/;
|
|
92
|
+
|
|
47
93
|
// Control chars: C0 range (U+0000–U+001F, includes \n \r \t) plus DEL (U+007F).
|
|
48
94
|
// A newline in frontmatter would corrupt the YAML parse — stripping these is
|
|
49
95
|
// critical. Written with \u escapes so the source stays pure ASCII.
|
|
50
96
|
// eslint-disable-next-line no-control-regex
|
|
51
97
|
const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
// Non-global twin of CONTROL_CHARS_RE for stateless `.test()` calls. A /g regex
|
|
100
|
+
// carries `lastIndex` across calls, so testing with CONTROL_CHARS_RE directly
|
|
101
|
+
// would alternate true/false on identical input. Derived from the same `.source`
|
|
102
|
+
// so the two can never drift — and so this line stays pure ASCII (a literal
|
|
103
|
+
// control char here would be a NUL-class corruption; see
|
|
104
|
+
// .claude/rules/proven-pattern-nul-byte-corruption...).
|
|
105
|
+
const CONTROL_CHARS_TEST_RE = new RegExp(CONTROL_CHARS_RE.source);
|
|
106
|
+
|
|
107
|
+
// Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
|
|
108
|
+
// (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
|
|
109
|
+
// stray '**' or '[ab]' entry (e.g. from an OLD learning record predating the
|
|
110
|
+
// #900 C / schema.mjs argv-boundary guards) must never reach the renderer
|
|
111
|
+
// verbatim, or it would produce an effectively always-on rule glob. Skipped
|
|
112
|
+
// here as defense-in-depth even though the argv (memory-propose.mjs) and
|
|
113
|
+
// schema (memory-proposals/schema.mjs) layers already reject these at write
|
|
114
|
+
// time — this emitter also processes learnings.jsonl entries that predate
|
|
115
|
+
// those guards.
|
|
116
|
+
const GLOB_METACHAR_RE = /[*?[\]{}]/;
|
|
67
117
|
|
|
68
118
|
/**
|
|
69
119
|
* Derive non-empty directory globs from a learning's `file_paths`.
|
|
70
120
|
*
|
|
71
121
|
* For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
|
|
72
122
|
* the file sits at the repo top level (`dirname` === '.'), emit the bare
|
|
73
|
-
* basename pattern instead of `./**` (e.g. `"foo.mjs"`).
|
|
74
|
-
*
|
|
123
|
+
* basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
|
|
124
|
+
* glob metacharacter (`* ? [ ] { }`) are skipped entirely — see
|
|
125
|
+
* {@link GLOB_METACHAR_RE} — as are entries carrying a control char or a quote
|
|
126
|
+
* character, which would break out of the renderer's ` - "<glob>"` sequence
|
|
127
|
+
* item into new top-level frontmatter keys (#1015; see
|
|
128
|
+
* {@link CONTROL_CHARS_TEST_RE} / {@link UNSAFE_PATH_QUOTE_RE}). Results are
|
|
129
|
+
* deduped, order-preserving on first occurrence.
|
|
75
130
|
*
|
|
76
131
|
* @param {string[]} filePaths
|
|
77
132
|
* @returns {string[]}
|
|
@@ -81,6 +136,10 @@ function globsFromFilePaths(filePaths) {
|
|
|
81
136
|
const seen = new Set();
|
|
82
137
|
for (const raw of filePaths) {
|
|
83
138
|
if (typeof raw !== 'string' || raw === '') continue;
|
|
139
|
+
if (GLOB_METACHAR_RE.test(raw)) continue;
|
|
140
|
+
// #1015: frontmatter-structure guard. Skipping (never escaping) keeps this
|
|
141
|
+
// idempotent and non-overlapping with the renderer's sanitiser.
|
|
142
|
+
if (CONTROL_CHARS_TEST_RE.test(raw) || UNSAFE_PATH_QUOTE_RE.test(raw)) continue;
|
|
84
143
|
const normalized = raw.replace(/\\/g, '/');
|
|
85
144
|
const dir = dirname(normalized);
|
|
86
145
|
const pattern = dir === '.' ? normalized : `${dir}/**`;
|
|
@@ -181,8 +240,13 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
|
|
|
181
240
|
* @param {object} [opts]
|
|
182
241
|
* @param {number} [opts.ruleExpiryDays] - explicit expiry window in days; when a
|
|
183
242
|
* finite number, overrides the per-type TTL.
|
|
184
|
-
* @param {number|Date} [opts.now] - injectable clock
|
|
185
|
-
*
|
|
243
|
+
* @param {number|Date} [opts.now] - injectable clock (defaults to `Date.now()`).
|
|
244
|
+
* Used for BOTH the born-dead expiry floor (`now + minRuleDays`, applied on
|
|
245
|
+
* every call — see {@link computeExpiresAt}) and, secondarily, as the base
|
|
246
|
+
* date when `created_at` is missing/unparseable. Because the floor consults
|
|
247
|
+
* it unconditionally, any test asserting a literal `expiresAt` MUST inject
|
|
248
|
+
* `now` — otherwise the wall clock eventually raises the floor past the
|
|
249
|
+
* derived expiry and the assertion silently starts measuring the floor.
|
|
186
250
|
* @param {number} [opts.minRuleDays] - floor window (days) applied to the
|
|
187
251
|
* emitted expiry so it never falls in the past — see
|
|
188
252
|
* {@link computeExpiresAt} and {@link MIN_RULE_DAYS_DEFAULT}. Defaults
|
|
@@ -199,6 +263,9 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
|
|
|
199
263
|
* }}
|
|
200
264
|
* @throws {Error} when no activation axis can be produced (empty globs AND no
|
|
201
265
|
* hostClass) — the never-always-on invariant.
|
|
266
|
+
* @throws {Error} when the learning has no derivable `learning_key` (no usable
|
|
267
|
+
* `type`, or no `title`/`subject` that slugs to a non-empty token) — a rule
|
|
268
|
+
* whose key resolves to no learning is worse than no rule.
|
|
202
269
|
*/
|
|
203
270
|
export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays } = {}) {
|
|
204
271
|
if (learning === null || typeof learning !== 'object' || Array.isArray(learning)) {
|
|
@@ -208,10 +275,25 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
|
|
|
208
275
|
const filePaths = Array.isArray(learning.file_paths) ? learning.file_paths : [];
|
|
209
276
|
const globs = globsFromFilePaths(filePaths);
|
|
210
277
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
278
|
+
// #1015: `host_class` is copied straight into an UNQUOTED `host-class:`
|
|
279
|
+
// frontmatter line by the renderer, so its SHAPE is load-bearing. Reject a
|
|
280
|
+
// malformed value loudly rather than degrade to "no host axis" — a
|
|
281
|
+
// structurally-invalid host_class is a corruption/injection signal, and
|
|
282
|
+
// silently emitting the rest of the rule from the same record would hide it.
|
|
283
|
+
// The engine wraps each learning in its own try/catch, so this throw degrades
|
|
284
|
+
// to ONE recorded rejection with an auditable reason, never a crashed run.
|
|
285
|
+
let hostClass;
|
|
286
|
+
if (typeof learning.host_class === 'string' && learning.host_class !== '') {
|
|
287
|
+
if (!HOST_CLASS_RE.test(learning.host_class)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`emitter: host_class has an unsafe shape (${JSON.stringify(learning.host_class)}) — refusing to emit; ` +
|
|
290
|
+
'the renderer serialises it as an unquoted `host-class:` line, so a newline / colon / quote there ' +
|
|
291
|
+
'injects sibling top-level frontmatter keys (e.g. alwaysApply: true + a far-future expires-at), ' +
|
|
292
|
+
`defeating the never-always-on brandmauer and the expiry sweep. Expected ${HOST_CLASS_RE.source}.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
hostClass = learning.host_class;
|
|
296
|
+
}
|
|
215
297
|
|
|
216
298
|
// The brandmauer: an auto-generated rule must carry ≥1 activation axis. If we
|
|
217
299
|
// could derive neither a glob nor a host-class, refuse — never emit always-on.
|
|
@@ -221,8 +303,30 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
|
|
|
221
303
|
);
|
|
222
304
|
}
|
|
223
305
|
|
|
224
|
-
|
|
225
|
-
|
|
306
|
+
// THE key. Derived through the shared `learningKeyOf` so the writer and every
|
|
307
|
+
// reader (engine's rejection path, validate/check-learning-provenance,
|
|
308
|
+
// learnings/candidates dedupe, claude-md-drift-check) compute one identity
|
|
309
|
+
// from one rule. This line previously kebab'd the TYPE half, which no reader
|
|
310
|
+
// does: harmless while every live type is kebab-identical, and silently
|
|
311
|
+
// divergent in both directions the moment one is not — the writer stamps one
|
|
312
|
+
// key into `.claude/rules/*.md` + `reconcile-candidates.jsonl` while every
|
|
313
|
+
// reader computes another, so dedupe stops matching with nothing failing.
|
|
314
|
+
//
|
|
315
|
+
// The injection hazard that motivated the kebab is closed downstream and
|
|
316
|
+
// LOUDLY: the key becomes an unquoted `learning-key:` frontmatter scalar, and
|
|
317
|
+
// `renderer.mjs` asserts `LEARNING_KEY_RE` (`/^[a-z0-9/-]+$/`) on it before
|
|
318
|
+
// rendering. A type carrying a newline/colon/quote is therefore rejected with
|
|
319
|
+
// an auditable reason instead of being silently re-keyed here — the same
|
|
320
|
+
// reject-don't-degrade posture as the `host_class` guard above.
|
|
321
|
+
const learningKey = learningKeyOf(learning);
|
|
322
|
+
if (learningKey === null) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
'emitter: unkeyable learning (no usable type, or no title/subject that slugs to a non-empty token) — ' +
|
|
325
|
+
'refusing to emit; `learning_key` is the dedupe identity, and a record without one would either ' +
|
|
326
|
+
'stamp a rule no reader can resolve back to a learning or collide with every other unkeyable record ' +
|
|
327
|
+
'of its type.',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
226
330
|
|
|
227
331
|
const confidence = typeof learning.confidence === 'number' ? learning.confidence : 0;
|
|
228
332
|
|