session-orchestrator 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* - Output collection: each gate captures the last ~50 lines of combined
|
|
27
27
|
* stdout+stderr (vs. `gate-helpers.mjs::runCheck` which truncates to 5).
|
|
28
28
|
* The longer tail flows into the diagnostics bundle and the fixer's
|
|
29
|
-
* failureContext.
|
|
29
|
+
* failureContext. Since #1427 the tail is cut by `process-group.mjs`'s own
|
|
30
|
+
* `OUTPUT_TAIL_LINES`, which is the same 50, and the full capture is
|
|
31
|
+
* byte-capped there instead of by `spawnSync`'s `maxBuffer`.
|
|
30
32
|
*
|
|
31
33
|
* - `last-green-sha.txt` lives at `.orchestrator/runtime/last-green-sha.txt`
|
|
32
34
|
* and is updated atomically after every successful gate. `changedFiles` is
|
|
@@ -67,6 +69,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
67
69
|
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
68
70
|
import { parsePorcelainZ } from './git-porcelain.mjs';
|
|
69
71
|
import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
|
|
72
|
+
import {
|
|
73
|
+
DEFAULT_GATE_TIMEOUT_MS,
|
|
74
|
+
buildCommandSignature,
|
|
75
|
+
spawnInGroup,
|
|
76
|
+
} from './process-group.mjs';
|
|
70
77
|
import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
71
78
|
import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
|
|
72
79
|
|
|
@@ -86,17 +93,20 @@ const DEFAULT_COMMANDS = {
|
|
|
86
93
|
test: 'npm test',
|
|
87
94
|
};
|
|
88
95
|
|
|
89
|
-
/** Max lines of combined stdout+stderr retained per failure. */
|
|
90
|
-
const OUTPUT_TAIL_LINES = 50;
|
|
91
|
-
|
|
92
96
|
/** Max corrective_context entries forwarded to the fixer (most-recent). */
|
|
93
97
|
const CORRECTIVE_CONTEXT_TAIL = 5;
|
|
94
98
|
|
|
95
99
|
/** Hard ceiling on retries — defensive coercion. */
|
|
96
100
|
const MAX_RETRIES_HARD_CAP = 10;
|
|
97
101
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Per-gate command timeout (15 min). Wave gates can be long; never infinite.
|
|
104
|
+
*
|
|
105
|
+
* Re-exported from `process-group.mjs` rather than re-spelled here: both gate
|
|
106
|
+
* paths are allowed exactly as long (PRD parameter `gate.timeout-path-b-ms`),
|
|
107
|
+
* and a second literal would drift from it silently.
|
|
108
|
+
*/
|
|
109
|
+
const GATE_TIMEOUT_MS = DEFAULT_GATE_TIMEOUT_MS;
|
|
100
110
|
|
|
101
111
|
// ---------------------------------------------------------------------------
|
|
102
112
|
// Internal helpers
|
|
@@ -274,29 +284,48 @@ function resolveCommands(override, repoRoot) {
|
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
/**
|
|
277
|
-
* Run a
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
287
|
+
* Run a gate command as the leader of its OWN process group, capture
|
|
288
|
+
* stdout+stderr, return the last ~50 lines plus the exit code.
|
|
289
|
+
*
|
|
290
|
+
* ASYNC since #1427 A1/A2, and that is the point rather than an incidental
|
|
291
|
+
* refactor. The previous `spawnSync(cmd, { shell: true, timeout })` made the
|
|
292
|
+
* SHELL the child, so Node's timeout signalled the shell alone and every
|
|
293
|
+
* grandchild — `tsgo --noEmit`, Vitest workers — was reparented to PID 1 and
|
|
294
|
+
* kept running. That is the 2026-09-20 incident: four orphaned `tsgo`
|
|
295
|
+
* processes, two at PPID 1, up to 8.0 GB RSS each, host at 13% free memory.
|
|
296
|
+
* {@link spawnInGroup} spawns `detached: true` (the child becomes a process-GROUP
|
|
297
|
+
* leader) and signals `-pgid` along the SIGTERM → grace → SIGKILL → verify
|
|
298
|
+
* ladder, so the whole group is gone when this resolves — or reported as a
|
|
299
|
+
* survivor, never booked as success (PRD B6).
|
|
300
|
+
*
|
|
301
|
+
* Does NOT throw — failures are encoded in the return value. The three fields
|
|
302
|
+
* the pre-#1427 contract carried (`exitCode`, `output`, `timedOut`) are
|
|
303
|
+
* unchanged; `overflow`, `killSignals`, `survivors` and `pgid` are additive.
|
|
304
|
+
*
|
|
305
|
+
* Exit-code contract, preserved verbatim from the synchronous path:
|
|
306
|
+
* - timeout → `124`
|
|
307
|
+
* - output over the byte cap → `1` (what `spawnSync`'s ENOBUFS produced)
|
|
308
|
+
* - otherwise the child's own code.
|
|
281
309
|
*
|
|
282
310
|
* @param {string} cmd
|
|
283
311
|
* @param {string} cwd
|
|
284
|
-
* @
|
|
312
|
+
* @param {object} [opts]
|
|
313
|
+
* @param {number} [opts.timeoutMs] — wall-clock ceiling. Defaults to GATE_TIMEOUT_MS.
|
|
314
|
+
* @param {string|null} [opts.sessionId] — owner recorded in the gate-process ledger.
|
|
315
|
+
* @param {object} [opts.seams] — forwarded verbatim to {@link spawnInGroup}
|
|
316
|
+
* (`spawnFn`, `killFn`, `isAliveFn`, `sleepFn`, `killGraceMs`, `verifyWaitMs`).
|
|
317
|
+
* Test seam only; production passes nothing and gets the module defaults.
|
|
318
|
+
* @returns {Promise<{ exitCode: number, output: string, timedOut: boolean,
|
|
319
|
+
* overflow: boolean, killSignals: string[], survivors: number[], pgid: number }>}
|
|
285
320
|
*/
|
|
286
|
-
function runGate(cmd, cwd) {
|
|
321
|
+
async function runGate(cmd, cwd, opts = {}) {
|
|
287
322
|
try {
|
|
288
323
|
// Gate commands are executable configuration from the caller or local
|
|
289
324
|
// Session Config, not data interpolated into a command template. Shell
|
|
290
325
|
// syntax is intentional; callers must trust both command sources (including
|
|
291
326
|
// uncommitted config). See security.md: Session Config Command Trust.
|
|
292
|
-
|
|
293
|
-
const result = spawnSync(cmd, {
|
|
327
|
+
const result = await spawnInGroup(cmd, {
|
|
294
328
|
cwd,
|
|
295
|
-
shell: true,
|
|
296
|
-
encoding: 'utf8',
|
|
297
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
|
-
timeout: GATE_TIMEOUT_MS,
|
|
299
|
-
maxBuffer: 16 * 1024 * 1024, // 16 MiB cap
|
|
300
329
|
// #1360: gate runs are where worker over-subscription was measured to
|
|
301
330
|
// cause timeout failures (integration fixtures spawn their own Node/npm/
|
|
302
331
|
// git children on top of Vitest's workers). `vitest.config.mjs` reads this
|
|
@@ -305,19 +334,40 @@ function runGate(cmd, cwd) {
|
|
|
305
334
|
// Inherited by every gate subprocess, not only the test one — typecheck
|
|
306
335
|
// and lint ignore it, so setting it unconditionally costs nothing.
|
|
307
336
|
env: { ...process.env, SO_BOUNDED_WORKERS: '1' },
|
|
337
|
+
timeoutMs: typeof opts.timeoutMs === 'number' ? opts.timeoutMs : GATE_TIMEOUT_MS,
|
|
338
|
+
// The ledger (A4) is what lets the orphan-reaper tell a descendant of OUR
|
|
339
|
+
// gate from a foreign process before it signals anything.
|
|
340
|
+
repoRoot: cwd,
|
|
341
|
+
commandSignature: buildCommandSignature(cmd),
|
|
342
|
+
sessionId: opts.sessionId ?? null,
|
|
343
|
+
...(opts.seams ?? {}),
|
|
308
344
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
: (
|
|
315
|
-
|
|
345
|
+
|
|
346
|
+
// A process that outlived SIGKILL (one that `setsid`-ed out of the group) is
|
|
347
|
+
// SAID OUT LOUD on the channel the operator and the diagnostics bundle both
|
|
348
|
+
// read. Silence here would be the exact failure the ladder exists to expose.
|
|
349
|
+
const output = result.survivors.length > 0
|
|
350
|
+
? `${result.output}\ngate: ${result.survivors.length} process(es) survived SIGKILL: ${result.survivors.join(', ')}`.trim()
|
|
351
|
+
: result.output;
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
exitCode: result.exitCode,
|
|
355
|
+
output,
|
|
356
|
+
timedOut: result.timedOut,
|
|
357
|
+
overflow: result.overflow,
|
|
358
|
+
killSignals: result.killSignals,
|
|
359
|
+
survivors: result.survivors,
|
|
360
|
+
pgid: result.pgid,
|
|
361
|
+
};
|
|
316
362
|
} catch (err) {
|
|
317
363
|
return {
|
|
318
364
|
exitCode: 1,
|
|
319
365
|
output: `quality-gate: failed to spawn command "${cmd}": ${err?.message ?? String(err)}`,
|
|
320
366
|
timedOut: false,
|
|
367
|
+
overflow: false,
|
|
368
|
+
killSignals: [],
|
|
369
|
+
survivors: [],
|
|
370
|
+
pgid: -1,
|
|
321
371
|
};
|
|
322
372
|
}
|
|
323
373
|
}
|
|
@@ -783,7 +833,7 @@ function coerceMaxRetries(n) {
|
|
|
783
833
|
* wrote it.
|
|
784
834
|
*
|
|
785
835
|
* Extraction margin: {@link suiteCountsFromOutput} sees only the
|
|
786
|
-
*
|
|
836
|
+
* 50-line tail `process-group.mjs` retains (`OUTPUT_TAIL_LINES` there). Measured on `npm test`
|
|
787
837
|
* (vitest 2026-07-31), the `Tests` summary line sits 5 lines from the end — 45
|
|
788
838
|
* lines of headroom. A runner epilogue longer than that (coverage table, long
|
|
789
839
|
* unhandled-error dump) pushes the summary out of the window; `counts` is then
|
|
@@ -833,6 +883,14 @@ async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
|
|
|
833
883
|
* @param {string} [opts.repoRoot] — defaults to process.cwd().
|
|
834
884
|
* @param {{lint?: string, typecheck?: string, test?: string}} [opts.commands]
|
|
835
885
|
* — override individual gate commands.
|
|
886
|
+
* @param {number} [opts.gateTimeoutMs] — per-gate wall-clock ceiling. Defaults to
|
|
887
|
+
* GATE_TIMEOUT_MS (15 min). On expiry the gate's
|
|
888
|
+
* whole process GROUP gets SIGTERM → grace →
|
|
889
|
+
* SIGKILL and the gate reports exit 124.
|
|
890
|
+
* @param {object} [opts._processSeams] — TEST SEAM, forwarded verbatim to
|
|
891
|
+
* `spawnInGroup` (`spawnFn`, `killFn`,
|
|
892
|
+
* `isAliveFn`, `sleepFn`, `killGraceMs`,
|
|
893
|
+
* `verifyWaitMs`). Production passes nothing.
|
|
836
894
|
*
|
|
837
895
|
* @returns {Promise<{
|
|
838
896
|
* ok: boolean,
|
|
@@ -851,6 +909,24 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
851
909
|
: async () => {};
|
|
852
910
|
const repoRoot = resolveRepoRoot(safeOpts.repoRoot);
|
|
853
911
|
const commands = resolveCommands(safeOpts.commands, repoRoot);
|
|
912
|
+
/**
|
|
913
|
+
* Per-gate spawn options, resolved ONCE per call.
|
|
914
|
+
*
|
|
915
|
+
* `sessionId` comes from the PROCESS-LOCAL witness only
|
|
916
|
+
* ({@link readOwnSessionIds} → `CLAUDE_CODE_SESSION_ID`), never from
|
|
917
|
+
* `.orchestrator/session.lock`: the lock is a repo-GLOBAL artefact any
|
|
918
|
+
* session in this working copy may hold, so reading it here would stamp a
|
|
919
|
+
* PEER's id onto our own gate processes and point the reaper at the wrong
|
|
920
|
+
* owner (`.claude/rules/identity-and-locks.md` — rank witnesses, never union
|
|
921
|
+
* them). No witness → `null`, which the ledger records as "owner unknown".
|
|
922
|
+
*/
|
|
923
|
+
const gateOpts = {
|
|
924
|
+
timeoutMs: typeof safeOpts.gateTimeoutMs === 'number' ? safeOpts.gateTimeoutMs : undefined,
|
|
925
|
+
sessionId: [...readOwnSessionIds()][0] ?? null,
|
|
926
|
+
seams: (typeof safeOpts._processSeams === 'object' && safeOpts._processSeams !== null)
|
|
927
|
+
? safeOpts._processSeams
|
|
928
|
+
: undefined,
|
|
929
|
+
};
|
|
854
930
|
|
|
855
931
|
// Accumulate per-attempt failure info for the diagnostics bundle.
|
|
856
932
|
const allFailures = [];
|
|
@@ -889,7 +965,10 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
889
965
|
|
|
890
966
|
for (const gate of GATE_ORDER) {
|
|
891
967
|
const cmd = commands[gate];
|
|
892
|
-
|
|
968
|
+
// `await` inside `for…of` is deliberate: the gates are fail-fast and
|
|
969
|
+
// SEQUENTIAL (lint → typecheck → test), exactly as the synchronous path
|
|
970
|
+
// ran them. Parallelising them would change the contract, not the plumbing.
|
|
971
|
+
const result = await runGate(cmd, repoRoot, gateOpts);
|
|
893
972
|
if (gate === 'test') {
|
|
894
973
|
// The numbers are in hand right here — capture them at the seam rather
|
|
895
974
|
// than letting them travel as prose (#954).
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backlog.mjs — LEAF module for the reconcile BACKLOG count (#1380 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Holds the side-effect-free half of the reconcile pipeline: the corpus/sidecar/
|
|
5
|
+
* provenance readers, the materialization partition, and the backlog count the
|
|
6
|
+
* session-start nudge judges on. Split out of `engine.mjs` so a read-only probe
|
|
7
|
+
* no longer has to import the WRITE-capable orchestrator to ask a counting
|
|
8
|
+
* question.
|
|
9
|
+
*
|
|
10
|
+
* ── Why a separate file, and what "leaf" binds to ───────────────────────────
|
|
11
|
+
* `scripts/lib/reconcile-nudge-banner.mjs` is a session-start probe: it renders
|
|
12
|
+
* one advisory line and touches nothing. Importing `countReconcileBacklog` from
|
|
13
|
+
* `engine.mjs` pulled the renderer, the emitter, the unicode-safety validator and
|
|
14
|
+
* the candidate-store writer into that probe's static closure — measured
|
|
15
|
+
* 2026-09-18: 11 modules / 151,946 B before the engine import, 19 modules /
|
|
16
|
+
* 322,897 B after (+112 %), inherited unasked by
|
|
17
|
+
* `scripts/lib/maintenance-due-banner.mjs`, which imports the probe.
|
|
18
|
+
* The runtime cost was never the problem (0.55 % of the 2-s probe budget); the
|
|
19
|
+
* DIRECTION was — a banner must not be able to reach a writer.
|
|
20
|
+
*
|
|
21
|
+
* So this module imports ONLY `node:*` plus light siblings that are themselves
|
|
22
|
+
* free of write paths: `../learnings/kebab.mjs`, `../learnings/schema.mjs`,
|
|
23
|
+
* `./eligibility.mjs` (pure partition), `../rule-loader.mjs` (#1387 — zero
|
|
24
|
+
* relative imports and no write paths of its own, measured 2026-09-18) and
|
|
25
|
+
* `./idempotency.mjs` (its READ half —
|
|
26
|
+
* `loadCandidates` / `isProcessed`; `mergeCandidates`, the only writer there, is
|
|
27
|
+
* never referenced from here). It MUST NOT import `./engine.mjs`,
|
|
28
|
+
* `./emitter.mjs`, `./renderer.mjs` or `./writer.mjs` — a future import of any
|
|
29
|
+
* of those re-creates exactly the coupling this file exists to break.
|
|
30
|
+
*
|
|
31
|
+
* `engine.mjs` imports from here and RE-EXPORTS `partitionMaterialized` +
|
|
32
|
+
* `countReconcileBacklog`, so every existing caller keeps working unchanged.
|
|
33
|
+
*
|
|
34
|
+
* Cross-references:
|
|
35
|
+
* - `scripts/lib/reconcile/engine.mjs` — the orchestrator (write side).
|
|
36
|
+
* - `scripts/lib/reconcile-nudge-banner.mjs` — the probe that imports this leaf.
|
|
37
|
+
* - `.claude/rules/host-resources.md` § HR-106 — the banner reports what the rule judges.
|
|
38
|
+
* - Issues #484 / #1242 (dedupe contract), #1380 (backlog vs. eligible).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
42
|
+
import { isAbsolute, join } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { learningKeyOf } from '../learnings/kebab.mjs';
|
|
45
|
+
import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
|
|
46
|
+
import { isRuleExpired, parseGlobsFrontmatter } from '../rule-loader.mjs';
|
|
47
|
+
import { filterEligible } from './eligibility.mjs';
|
|
48
|
+
import { isProcessed, loadCandidates as realLoadCandidates } from './idempotency.mjs';
|
|
49
|
+
|
|
50
|
+
/** Default repo-relative location of the learnings corpus. */
|
|
51
|
+
export const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default learnings loader — read + parse `<repoRoot>/.orchestrator/metrics/learnings.jsonl`
|
|
55
|
+
* line-by-line, migrate/normalize records through the learnings schema SSOT,
|
|
56
|
+
* and skip blank/malformed lines. A missing file (ENOENT) yields `[]`
|
|
57
|
+
* silently; an unreadable one (EACCES/EISDIR/…) yields `[]` with a stderr
|
|
58
|
+
* WARN (#1210 — ENOENT and other read failures are different facts, same
|
|
59
|
+
* split as `sessions-canonical.mjs` `readCanonicalSessions`).
|
|
60
|
+
*
|
|
61
|
+
* @param {string|undefined} repoRoot
|
|
62
|
+
* @returns {Array<Record<string, unknown>>}
|
|
63
|
+
*/
|
|
64
|
+
export function defaultLoadLearnings(repoRoot) {
|
|
65
|
+
const root = typeof repoRoot === 'string' && repoRoot.length > 0 ? repoRoot : process.cwd();
|
|
66
|
+
const absPath = isAbsolute(DEFAULT_LEARNINGS_PATH)
|
|
67
|
+
? DEFAULT_LEARNINGS_PATH
|
|
68
|
+
: join(root, DEFAULT_LEARNINGS_PATH);
|
|
69
|
+
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = readFileSync(absPath, 'utf8');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!err || err.code !== 'ENOENT') {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`⚠ defaultLoadLearnings: cannot read ${absPath} ` +
|
|
77
|
+
`(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
|
|
78
|
+
'treating as EMPTY, counts below are floors\n',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
85
|
+
const records = [];
|
|
86
|
+
for (const line of raw.split('\n')) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (trimmed.length === 0) continue;
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(trimmed);
|
|
92
|
+
} catch {
|
|
93
|
+
continue; // skip malformed line
|
|
94
|
+
}
|
|
95
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
96
|
+
records.push(
|
|
97
|
+
/** @type {Record<string, unknown>} */ (normalizeLearning(migrateLegacyLearning(parsed))),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return records;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bring an INJECTED corpus onto the same dialect {@link defaultLoadLearnings}
|
|
106
|
+
* produces, so "which learnings were counted" cannot depend on WHO read the file.
|
|
107
|
+
*
|
|
108
|
+
* The nudge loads the corpus through `readLearnings` (`../learnings/io.mjs`),
|
|
109
|
+
* which applies `normalizeLearning` but NOT `migrateLegacyLearning` — so a
|
|
110
|
+
* record still carrying the legacy `files` key would reach the eligibility gate
|
|
111
|
+
* without its `file_paths` axis and be rejected here while `/reconcile` (which
|
|
112
|
+
* loads via `defaultLoadLearnings`) accepts it. Two populations in one banner
|
|
113
|
+
* line is the HR-106 defect; running the injected corpus through the same two
|
|
114
|
+
* functions removes the fork at its source rather than asking every caller to
|
|
115
|
+
* remember.
|
|
116
|
+
*
|
|
117
|
+
* Both functions are idempotent on already-normalized records (verified
|
|
118
|
+
* 2026-09-18 against this repo's corpus), so the pass is a no-op for a corpus
|
|
119
|
+
* that already went through the loader. A record the normalizer rejects is kept
|
|
120
|
+
* VERBATIM rather than dropped: the eligibility gate downstream is the place
|
|
121
|
+
* that decides a record's fate, and silently shrinking the denominator here
|
|
122
|
+
* would under-report the backlog.
|
|
123
|
+
*
|
|
124
|
+
* @param {Array<Record<string, unknown>>} learnings
|
|
125
|
+
* @returns {Array<Record<string, unknown>>}
|
|
126
|
+
*/
|
|
127
|
+
function normalizeInjectedCorpus(learnings) {
|
|
128
|
+
return learnings.map((entry) => {
|
|
129
|
+
try {
|
|
130
|
+
return /** @type {Record<string, unknown>} */ (normalizeLearning(migrateLegacyLearning(entry)));
|
|
131
|
+
} catch {
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Default sidecar-candidate loader for the issue #484 idempotency dedupe
|
|
139
|
+
* check. Deliberately gated on `repoRoot` being a caller-supplied, non-empty
|
|
140
|
+
* string — UNLIKE `defaultLoadLearnings` and `mergeCandidates`, this does NOT
|
|
141
|
+
* fall back to `process.cwd()` when `repoRoot` is absent. Every existing engine
|
|
142
|
+
* test exercises the pipeline via injected learnings with no `repoRoot`,
|
|
143
|
+
* precisely to avoid touching this repo's OWN
|
|
144
|
+
* `.orchestrator/runtime/reconcile-candidates.jsonl`; a cwd fallback here would
|
|
145
|
+
* silently read it.
|
|
146
|
+
*
|
|
147
|
+
* @param {string|undefined} repoRoot
|
|
148
|
+
* @returns {{ records: import('./idempotency.mjs').ReconcileCandidate[] }}
|
|
149
|
+
*/
|
|
150
|
+
export function defaultLoadCandidatesForDedupe(repoRoot) {
|
|
151
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { records: [] };
|
|
152
|
+
const { records } = realLoadCandidates({ repoRoot });
|
|
153
|
+
return { records };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Frontmatter form emitted by renderer.mjs: `learning-key: <value>` (no backticks, no leading dash). */
|
|
157
|
+
const FRONTMATTER_LEARNING_KEY_RE = /^learning-key:\s*(.+)$/gm;
|
|
158
|
+
/** Provenance-body form emitted by renderer.mjs: `` - learning-key: `<value>` ``. */
|
|
159
|
+
const BODY_LEARNING_KEY_RE = /-\s*learning-key:\s*`([^`]+)`/g;
|
|
160
|
+
/** Provenance-body form emitted by renderer.mjs: `` - learning-id: `<value>` ``. */
|
|
161
|
+
const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scan `<repoRoot>/.claude/rules/*.md` for the provenance markers the
|
|
165
|
+
* renderer stamps on every machine-generated rule — the frontmatter
|
|
166
|
+
* `learning-key:` line and the body `## Provenance` block's `learning-key`/
|
|
167
|
+
* `learning-id` bullets (`renderer.mjs`) — and return the two identity sets a
|
|
168
|
+
* learning can already be materialized under. A learning whose derived
|
|
169
|
+
* `learning_key` OR raw `.id` appears in either set already has a rule file
|
|
170
|
+
* on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
|
|
171
|
+
* run were learnings a `.claude/rules/` file already covered).
|
|
172
|
+
*
|
|
173
|
+
* **This scan is the AUTHORITATIVE half of the dedupe contract** (#1242). The
|
|
174
|
+
* `.claude/rules/*.md` files it reads are TRACKED, so they survive a fresh
|
|
175
|
+
* clone, a wiped working copy, and any loss of `.orchestrator/runtime/` (which
|
|
176
|
+
* is gitignored — `.gitignore:114`). The idempotency sidecar consulted beside
|
|
177
|
+
* it is a CACHE that can only SHORT-CIRCUIT this scan, never replace it: on a
|
|
178
|
+
* fresh clone the sidecar is empty and correctness rests entirely on the
|
|
179
|
+
* markers below. Measured 2026-09-07 on this repo: with the sidecar emptied,
|
|
180
|
+
* the run produced the identical 10 proposals and 30 "already materialized"
|
|
181
|
+
* rejections; with this scan disabled instead, 5 already-consolidated
|
|
182
|
+
* learnings were re-proposed.
|
|
183
|
+
*
|
|
184
|
+
* Both marker forms are load-bearing. Frontmatter `learning-key:` is a YAML
|
|
185
|
+
* SCALAR and can name exactly ONE learning, so a CONSOLIDATED rule file (one
|
|
186
|
+
* file absorbing N learnings) carries the remaining N-1 identities ONLY as
|
|
187
|
+
* `## Provenance` body bullets. Breaking {@link BODY_LEARNING_KEY_RE} would
|
|
188
|
+
* therefore silently re-propose most of a consolidated corpus while every
|
|
189
|
+
* single-learning file still deduped correctly — pinned by the "fresh clone,
|
|
190
|
+
* consolidated shape" test in `tests/lib/reconcile/engine.test.mjs`.
|
|
191
|
+
*
|
|
192
|
+
* An EXPIRED rule file is skipped WHOLE — frontmatter key and body bullets
|
|
193
|
+
* alike (#1387). `rule-loader.mjs` already refuses to inject such a file, so
|
|
194
|
+
* counting its markers here declared a learning materialized by a rule that no
|
|
195
|
+
* longer reaches any agent: materialized forever, re-proposable never. The
|
|
196
|
+
* expiry verdict comes from `isRuleExpired` in `rule-loader.mjs` — the SAME
|
|
197
|
+
* predicate the injection gate uses, and fail-open the same way (an absent or
|
|
198
|
+
* unparseable `expires-at` is NOT expired; fail-closed would un-materialize the
|
|
199
|
+
* whole corpus on one typo).
|
|
200
|
+
*
|
|
201
|
+
* KNOWN LIMIT (measured 2026-09-18): this covers only the on-disk half of
|
|
202
|
+
* {@link partitionMaterialized} (`sidecarTerminal || onDisk`). 79 of this
|
|
203
|
+
* repo's 92 provenance keys are ALSO terminal in the idempotency sidecar, so
|
|
204
|
+
* only 13 actually become re-proposable; the sidecar half is left untouched on
|
|
205
|
+
* purpose because it carries operator DECLINES. See `docs/rule-authoring.md`
|
|
206
|
+
* § Consolidated rules.
|
|
207
|
+
*
|
|
208
|
+
* Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
|
|
209
|
+
* `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
|
|
210
|
+
* Never throws — a missing `.claude/rules/` dir or an unreadable file
|
|
211
|
+
* degrades to "nothing materialized" for that source, never a crash.
|
|
212
|
+
* @param {string|undefined} repoRoot
|
|
213
|
+
* @returns {{ keys: Set<string>, ids: Set<string> }}
|
|
214
|
+
*/
|
|
215
|
+
export function defaultReadMaterializedProvenance(repoRoot) {
|
|
216
|
+
const keys = new Set();
|
|
217
|
+
const ids = new Set();
|
|
218
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { keys, ids };
|
|
219
|
+
|
|
220
|
+
// One clock reading for the whole scan, so two files cannot be judged
|
|
221
|
+
// against two different "now"s. Positional-only signature by contract (the
|
|
222
|
+
// engine's DI seam types it as `(repoRoot) => …`), hence no injectable clock.
|
|
223
|
+
const nowMs = Date.now();
|
|
224
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
225
|
+
let entries;
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(rulesDir);
|
|
228
|
+
} catch {
|
|
229
|
+
return { keys, ids }; // no rules dir yet → nothing materialized
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (!entry.endsWith('.md')) continue;
|
|
234
|
+
let content;
|
|
235
|
+
try {
|
|
236
|
+
content = readFileSync(join(rulesDir, entry), 'utf8');
|
|
237
|
+
} catch {
|
|
238
|
+
continue; // unreadable file — skip it, do not fail the whole scan
|
|
239
|
+
}
|
|
240
|
+
// #1387 — an expired rule no longer reaches any agent, so its markers must
|
|
241
|
+
// not keep their learnings materialized. Malformed frontmatter throws here
|
|
242
|
+
// and is treated as "no expiry" (fail-open, same direction as the gate).
|
|
243
|
+
let expiresAt;
|
|
244
|
+
try {
|
|
245
|
+
({ 'expires-at': expiresAt } = parseGlobsFrontmatter(content).meta);
|
|
246
|
+
} catch {
|
|
247
|
+
expiresAt = undefined;
|
|
248
|
+
}
|
|
249
|
+
if (isRuleExpired(expiresAt, nowMs)) continue;
|
|
250
|
+
for (const m of content.matchAll(FRONTMATTER_LEARNING_KEY_RE)) {
|
|
251
|
+
const v = m[1].trim();
|
|
252
|
+
if (v) keys.add(v);
|
|
253
|
+
}
|
|
254
|
+
for (const m of content.matchAll(BODY_LEARNING_KEY_RE)) {
|
|
255
|
+
const v = m[1].trim();
|
|
256
|
+
if (v) keys.add(v);
|
|
257
|
+
}
|
|
258
|
+
for (const m of content.matchAll(BODY_LEARNING_ID_RE)) {
|
|
259
|
+
const v = m[1].trim();
|
|
260
|
+
if (v && v !== 'n/a') ids.add(v);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { keys, ids };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Split rule-eligible learnings into the ones still awaiting a rule and the ones
|
|
268
|
+
* already materialized (#484/#1242 dedupe contract). A learning is materialized
|
|
269
|
+
* when the idempotency sidecar carries a terminal verdict for its `learning_key`
|
|
270
|
+
* (CACHE) OR a `.claude/rules/*.md` provenance marker names its key/id
|
|
271
|
+
* (AUTHORITATIVE).
|
|
272
|
+
*
|
|
273
|
+
* Pure: reads nothing, writes nothing, emits nothing — the engine and the
|
|
274
|
+
* session-start reconcile nudge share it so both judge the same backlog (#1380).
|
|
275
|
+
*
|
|
276
|
+
* @param {Array<Record<string, unknown>>} eligible - output of `filterEligible().eligible`.
|
|
277
|
+
* @param {{ existingCandidates: import('./idempotency.mjs').ReconcileCandidate[], materialized: { keys: Set<string>, ids: Set<string> } }} sources
|
|
278
|
+
* @returns {{
|
|
279
|
+
* stillEligible: Array<Record<string, unknown>>,
|
|
280
|
+
* materializedItems: Array<{ learning: Record<string, unknown>, learningKey: string|null, sidecarTerminal: boolean, onDisk: boolean }>,
|
|
281
|
+
* }}
|
|
282
|
+
*/
|
|
283
|
+
export function partitionMaterialized(eligible, { existingCandidates, materialized }) {
|
|
284
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
285
|
+
const stillEligible = [];
|
|
286
|
+
const materializedItems = [];
|
|
287
|
+
for (const learning of eligible) {
|
|
288
|
+
const learningKey = learningKeyOf(learning);
|
|
289
|
+
const learningId =
|
|
290
|
+
learning &&
|
|
291
|
+
typeof learning === 'object' &&
|
|
292
|
+
typeof learning.id === 'string' &&
|
|
293
|
+
learning.id.length > 0
|
|
294
|
+
? learning.id
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
const sidecarTerminal =
|
|
298
|
+
learningKey !== null && isProcessed({ learning_key: learningKey }, existingCandidates);
|
|
299
|
+
const onDisk =
|
|
300
|
+
(learningKey !== null && materialized.keys.has(learningKey)) ||
|
|
301
|
+
(learningId !== null && materialized.ids.has(learningId));
|
|
302
|
+
|
|
303
|
+
if (!sidecarTerminal && !onDisk) {
|
|
304
|
+
stillEligible.push(learning);
|
|
305
|
+
} else {
|
|
306
|
+
materializedItems.push({ learning, learningKey, sidecarTerminal, onDisk });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { stillEligible, materializedItems };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Count the reconcile BACKLOG the way a real `/reconcile` run would see it:
|
|
314
|
+
* rule-eligible learnings (expiry-gated by `now`) minus those already
|
|
315
|
+
* materialized in the sidecar or under `.claude/rules/`. This is the quantity
|
|
316
|
+
* `/reconcile` can actually drive to zero — the session-start nudge judges on it
|
|
317
|
+
* (#1380; HR-101: a probe that counts already-materialized learnings can never
|
|
318
|
+
* go green).
|
|
319
|
+
*
|
|
320
|
+
* Side-effect-free (no event, no sidecar write) so session-start probes may call
|
|
321
|
+
* it. Never throws — any failure degrades to all-zero counts.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} [params]
|
|
324
|
+
* @param {string} [params.repoRoot] - repo root; required for the sidecar + rules scan
|
|
325
|
+
* (absent → both sources are empty, same gate as the engine defaults).
|
|
326
|
+
* @param {Array<Record<string, unknown>>} [params.learnings] - injected corpus;
|
|
327
|
+
* defaults to {@link defaultLoadLearnings} for
|
|
328
|
+
* `<repoRoot>/.orchestrator/metrics/learnings.jsonl`. An injected corpus is
|
|
329
|
+
* run through {@link normalizeInjectedCorpus} first, so injecting can never
|
|
330
|
+
* silently count a DIFFERENT population than loading would.
|
|
331
|
+
* @param {import('./idempotency.mjs').ReconcileCandidate[]} [params.existingCandidates]
|
|
332
|
+
* already-loaded sidecar records (DI seam for a caller that read the store
|
|
333
|
+
* itself — the session-start nudge does, for the last-run timestamp). Absent
|
|
334
|
+
* ⇒ this function reads the store via {@link defaultLoadCandidatesForDedupe}.
|
|
335
|
+
* @param {Date|number} [params.now] - clock for the expiry gate (defaults to `Date.now()`).
|
|
336
|
+
* @param {number} [params.minInsightChars] - forwarded to `filterEligible`.
|
|
337
|
+
* @returns {{ eligible: number, alreadyMaterialized: number, backlog: number }}
|
|
338
|
+
*/
|
|
339
|
+
export function countReconcileBacklog({
|
|
340
|
+
repoRoot,
|
|
341
|
+
learnings,
|
|
342
|
+
existingCandidates,
|
|
343
|
+
now,
|
|
344
|
+
minInsightChars,
|
|
345
|
+
} = {}) {
|
|
346
|
+
try {
|
|
347
|
+
const corpus = Array.isArray(learnings)
|
|
348
|
+
? normalizeInjectedCorpus(learnings)
|
|
349
|
+
: defaultLoadLearnings(repoRoot);
|
|
350
|
+
if (corpus.length === 0) return { eligible: 0, alreadyMaterialized: 0, backlog: 0 };
|
|
351
|
+
const nowMs =
|
|
352
|
+
now instanceof Date ? now.getTime() : typeof now === 'number' ? now : Date.now();
|
|
353
|
+
const { eligible } = filterEligible(corpus, { now: nowMs, minInsightChars });
|
|
354
|
+
const { stillEligible, materializedItems } = partitionMaterialized(eligible, {
|
|
355
|
+
existingCandidates: Array.isArray(existingCandidates)
|
|
356
|
+
? existingCandidates
|
|
357
|
+
: defaultLoadCandidatesForDedupe(repoRoot).records,
|
|
358
|
+
materialized: defaultReadMaterializedProvenance(repoRoot),
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
eligible: eligible.length,
|
|
362
|
+
alreadyMaterialized: materializedItems.length,
|
|
363
|
+
backlog: stillEligible.length,
|
|
364
|
+
};
|
|
365
|
+
} catch {
|
|
366
|
+
return { eligible: 0, alreadyMaterialized: 0, backlog: 0 };
|
|
367
|
+
}
|
|
368
|
+
}
|