session-orchestrator 3.22.0 → 3.24.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-start-probes.mjs — the mechanical runner for the Phase 4 probe family.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists
|
|
5
|
+
*
|
|
6
|
+
* `skills/session-start/SKILL.md` § Phase 4 names 18 measurement probes, each
|
|
7
|
+
* with a module path and an entry function. Measured 2026-08-23 at `4f6404e`,
|
|
8
|
+
* NONE of them had a mechanical caller:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* $ grep -c "session-start-probes\|checkSessionsStaleness\|checkProjectHygiene" \
|
|
12
|
+
* hooks/*.mjs .husky/* package.json .gitlab-ci.yml
|
|
13
|
+
* 0
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The only caller was prose in a SKILL.md — i.e. an LLM had to remember to run
|
|
17
|
+
* eighteen probes by hand, every session. And whether it ever did was itself
|
|
18
|
+
* unobservable: 336 recorded session starts carried no probe event of any kind.
|
|
19
|
+
* That is `.claude/rules/host-resources.md` § HR-105 ("a rule you cannot
|
|
20
|
+
* falsify is not a rule") applied to a whole probe family, and the repo-memory
|
|
21
|
+
* "built-but-not-wired" class in its purest form.
|
|
22
|
+
*
|
|
23
|
+
* This runner is the wiring: it invokes the probes, collects their banner lines
|
|
24
|
+
* into the caller's single-envelope buffer, and writes ONE
|
|
25
|
+
* `orchestrator.probes.completed` telemetry record per run so the next question
|
|
26
|
+
* — "did they run, and what did they find?" — is answerable from the ledger
|
|
27
|
+
* rather than from memory.
|
|
28
|
+
*
|
|
29
|
+
* ## Contract
|
|
30
|
+
*
|
|
31
|
+
* - **Fail-open, always.** A probe that throws, hangs, or is missing from the
|
|
32
|
+
* install produces an `outcome`, never an exception. `runSessionStartProbes`
|
|
33
|
+
* has no rejecting path; a caller needs no try/catch (the hook keeps one
|
|
34
|
+
* anyway as defence-in-depth).
|
|
35
|
+
* - **Budget-bounded.** The whole run shares one deadline (`PROBE_BUDGET_MS`).
|
|
36
|
+
* See the ceiling note on that constant for what the bound can and cannot do.
|
|
37
|
+
* - **Absent is not zero.** A probe that did not run is recorded with the
|
|
38
|
+
* reason it did not (`skipped` + `reason`), never silently omitted and never
|
|
39
|
+
* folded into a clean count.
|
|
40
|
+
*
|
|
41
|
+
* @module scripts/lib/session-start-probes
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { pathToFileURL } from 'node:url';
|
|
47
|
+
|
|
48
|
+
import { emitEvent } from './events.mjs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a sibling module to an absolute `file:` URL.
|
|
52
|
+
*
|
|
53
|
+
* A bare relative specifier (`'./ci-status-banner.mjs'`) resolves against the
|
|
54
|
+
* module doing the `import()`, so the registry would only be importable from
|
|
55
|
+
* THIS file — any other consumer, a test included, would resolve every entry
|
|
56
|
+
* against its own directory and see eighteen phantom "module-absent" skips that
|
|
57
|
+
* look exactly like a stale plugin install. Pinning the specifier to this
|
|
58
|
+
* module's directory makes `PROBES` self-contained.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} rel — path relative to `scripts/lib/`
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Budget
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wall-clock ceiling for the ENTIRE probe run, in milliseconds.
|
|
71
|
+
*
|
|
72
|
+
* Derived from measurement, not aspiration:
|
|
73
|
+
* - `hooks/hooks.json` gives the whole SessionStart hook `timeout: 5` (5s).
|
|
74
|
+
* Everything below is carved out of that, not out of nothing.
|
|
75
|
+
* - The pre-existing work in the same hook already spends a measured ~845 ms
|
|
76
|
+
* median on `backfillOnSessionStart` alone, plus git, registry and the
|
|
77
|
+
* resource probe.
|
|
78
|
+
* - Measured 2026-08-23 against this repo (the largest in the fleet), one
|
|
79
|
+
* `runSessionStartProbes` call per process, five runs:
|
|
80
|
+
* **855 / 916 / 968 / 1063 / 1104 ms** — median 968 ms, dominated by
|
|
81
|
+
* `project-hygiene` and `tests-src-ratio`, the two probes that shell out.
|
|
82
|
+
*
|
|
83
|
+
* 2000 ms is ~2x the measured median here and still leaves the hook well over
|
|
84
|
+
* half its 5s envelope. Note the headroom is real but not generous, and this is
|
|
85
|
+
* the WORST repo in the fleet by design — see the revisit trigger below, which
|
|
86
|
+
* this repo already sits just underneath rather than comfortably below.
|
|
87
|
+
*
|
|
88
|
+
* NAMED CEILING (BV-004): the deadline is enforced at `await` points. A probe
|
|
89
|
+
* that blocks the event loop *synchronously* (several do — `project-hygiene`
|
|
90
|
+
* and `tests-src-ratio` shell out with `execFileSync`) cannot be preempted by a
|
|
91
|
+
* timer that cannot run; such a probe is reported with its TRUE `durationMs`
|
|
92
|
+
* and can overrun this budget. The bound is therefore hard for async/network
|
|
93
|
+
* probes and advisory for synchronous ones.
|
|
94
|
+
*
|
|
95
|
+
* READING `durationMs`: probes are launched together, so a probe's individual
|
|
96
|
+
* `durationMs` includes time spent waiting for a SIBLING synchronous probe to
|
|
97
|
+
* release the event loop. Measured 2026-08-23 here, `loop-readiness` reports
|
|
98
|
+
* ~513 ms under parallel launch and 0.5 ms in isolation. Only the run-level
|
|
99
|
+
* `duration_ms` is an isolated cost; per-probe figures rank contention, not
|
|
100
|
+
* work.
|
|
101
|
+
*
|
|
102
|
+
* REVISIT TRIGGER: if `duration_ms` in `orchestrator.probes.completed` exceeds
|
|
103
|
+
* half this budget at the median across a repo's recorded starts, or if any
|
|
104
|
+
* single probe's `durationMs` regularly exceeds `PROBE_BUDGET_MS`, move the
|
|
105
|
+
* slow probes off the hook's critical path (a detached child process writing
|
|
106
|
+
* its banner for the NEXT start) rather than raising this number.
|
|
107
|
+
*/
|
|
108
|
+
export const PROBE_BUDGET_MS = 2000;
|
|
109
|
+
|
|
110
|
+
/** Sentinel resolved by the deadline race; never leaks to a caller. */
|
|
111
|
+
const TIMED_OUT = Symbol('probe-timeout');
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Probe registry
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The Phase 4 probe family, in the order `skills/session-start/SKILL.md`
|
|
119
|
+
* introduces them (banner order is registry order, never completion order, so
|
|
120
|
+
* a run is reproducible).
|
|
121
|
+
*
|
|
122
|
+
* Census (2026-08-23, `skills/session-start/SKILL.md` lines 693-835):
|
|
123
|
+
* sed -n '693,835p' skills/session-start/SKILL.md \
|
|
124
|
+
* | grep -oE 'via `(await )?check[A-Za-z]+' # -> 16
|
|
125
|
+
* plus two probes the prose introduces with different phrasing —
|
|
126
|
+
* `checkBootstrapLockFreshness` ("invoke the bootstrap-lock-freshness probe")
|
|
127
|
+
* and `checkVaultStaleness` ("read the most recent line via …") — for 18.
|
|
128
|
+
*
|
|
129
|
+
* Each entry:
|
|
130
|
+
* - `id` stable telemetry id (also the banner-ordering key)
|
|
131
|
+
* - `spec` absolute `file:` URL to import (see {@link local})
|
|
132
|
+
* - `fn` exported entry function name
|
|
133
|
+
* - `network` true when the probe spawns a VCS CLI that talks to a remote
|
|
134
|
+
* - `args` builds the probe's options object from the run context
|
|
135
|
+
* - `precondition` optional; returns a skip-reason string to skip the probe
|
|
136
|
+
* - `render` optional; maps a result to a banner line. Default:
|
|
137
|
+
* `result.message` when severity is warn/alert.
|
|
138
|
+
*/
|
|
139
|
+
export const PROBES = [
|
|
140
|
+
{
|
|
141
|
+
id: 'bootstrap-lock-freshness',
|
|
142
|
+
spec: local('./bootstrap-lock-freshness.mjs'),
|
|
143
|
+
fn: 'checkBootstrapLockFreshness',
|
|
144
|
+
network: false,
|
|
145
|
+
// SKILL.md gates this one on the lock's existence: without a lock the probe
|
|
146
|
+
// returns a hard `alert` ("bootstrap.lock missing") that is not a finding
|
|
147
|
+
// about THIS repo but about it never having been bootstrapped.
|
|
148
|
+
precondition: ({ repoRoot }) =>
|
|
149
|
+
existsSync(path.join(repoRoot, '.orchestrator', 'bootstrap.lock'))
|
|
150
|
+
? null
|
|
151
|
+
: 'no-bootstrap-lock',
|
|
152
|
+
args: ({ repoRoot }) => ({ repoRoot, currentPluginVersion: pluginVersion() }),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'vault-staleness',
|
|
156
|
+
spec: local('./vault-staleness-banner.mjs'),
|
|
157
|
+
fn: 'checkVaultStaleness',
|
|
158
|
+
network: false,
|
|
159
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
160
|
+
// No custom severityOf needed (#1159 single-vocabulary fix, N3 in the
|
|
161
|
+
// #1158/#1159 review): the probe's THIRD shape — `{severity:'warn',
|
|
162
|
+
// kind:'probe-stale'}` when its last record is older than
|
|
163
|
+
// MAX_RECORD_AGE_DAYS — now carries `severity: 'warn'` directly, so the
|
|
164
|
+
// module-level default severityOf() below (which reads `result.severity`
|
|
165
|
+
// verbatim) already renders it. The registry previously remapped a
|
|
166
|
+
// distinct `severity: 'info'` value by hand; that second vocabulary is
|
|
167
|
+
// gone from the source, so the remap is gone here too. See
|
|
168
|
+
// vault-staleness-banner.mjs's header for the "one vocabulary, not two"
|
|
169
|
+
// rationale.
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'ci-status',
|
|
173
|
+
spec: local('./ci-status-banner.mjs'),
|
|
174
|
+
fn: 'checkCiStatus',
|
|
175
|
+
network: true,
|
|
176
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
177
|
+
// Bespoke shape: `{status, ok, details, …}` with no `message` field. The
|
|
178
|
+
// banner text is prescribed by SKILL.md § Phase 4.
|
|
179
|
+
render: (r) => {
|
|
180
|
+
if (!r || typeof r !== 'object') return null;
|
|
181
|
+
if (r.status === 'red') {
|
|
182
|
+
const pid = r.details?.currentPipelineId ?? '?';
|
|
183
|
+
const green = r.lastGreen
|
|
184
|
+
? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
|
|
185
|
+
: '';
|
|
186
|
+
const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
|
|
187
|
+
return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
|
|
188
|
+
}
|
|
189
|
+
if (r.status === 'green' && Array.isArray(r.allowFailureJobs) && r.allowFailureJobs.length > 0) {
|
|
190
|
+
const names = r.allowFailureJobs.map((j) => j?.name ?? String(j)).join(', ');
|
|
191
|
+
return `⚠ CI green on HEAD, but ${r.allowFailureJobs.length} allow_failure job(s) FAILED: ${names}. A pipeline reports success regardless of these.`;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
},
|
|
195
|
+
// `status: 'red'` is an alert even though the probe publishes no severity.
|
|
196
|
+
severityOf: (r) => (r?.status === 'red' ? 'alert' : r?.status === 'green' && r?.allowFailureJobs ? 'warn' : 'ok'),
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 'qg-command-drift',
|
|
200
|
+
spec: local('./qg-command-drift-banner.mjs'),
|
|
201
|
+
fn: 'checkQgCommandDrift',
|
|
202
|
+
network: false,
|
|
203
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'peer-cards-staleness',
|
|
207
|
+
spec: local('./peer-cards/staleness-banner.mjs'),
|
|
208
|
+
fn: 'checkPeerCardsStaleness',
|
|
209
|
+
network: false,
|
|
210
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'loop-readiness',
|
|
214
|
+
spec: local('./loop-readiness-banner.mjs'),
|
|
215
|
+
fn: 'checkLoopReadiness',
|
|
216
|
+
network: false,
|
|
217
|
+
args: ({ repoRoot, env }) => ({ repoRoot, env }),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'instruction-budget',
|
|
221
|
+
spec: local('./instruction-budget-guard.mjs'),
|
|
222
|
+
fn: 'checkInstructionBudget',
|
|
223
|
+
network: false,
|
|
224
|
+
// No precondition (#1132): this probe counts the always-on directive
|
|
225
|
+
// corpus under `.claude/rules/`, and a repo without that directory has an
|
|
226
|
+
// empty corpus — a legitimate measured answer, not a declined measurement.
|
|
227
|
+
// A `no-rules-dir` precondition used to sit here purely to dodge a stderr
|
|
228
|
+
// side-effect in `rule-loader.loadApplicableRules`; that side-effect is now
|
|
229
|
+
// category-gated at its source, so the precondition suppressed nothing and
|
|
230
|
+
// cost a misleading `skipped: 'no-rules-dir'` record.
|
|
231
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'reconcile-nudge',
|
|
235
|
+
spec: local('./reconcile-nudge-banner.mjs'),
|
|
236
|
+
fn: 'checkReconcileNudge',
|
|
237
|
+
network: false,
|
|
238
|
+
args: ({ repoRoot, config }) => ({ repoRoot, config }),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: 'sessions-staleness',
|
|
242
|
+
spec: local('./sessions-staleness-banner.mjs'),
|
|
243
|
+
fn: 'checkSessionsStaleness',
|
|
244
|
+
network: false,
|
|
245
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'sessions-integrity',
|
|
249
|
+
spec: local('./sessions-integrity-banner.mjs'),
|
|
250
|
+
fn: 'checkSessionsIntegrity',
|
|
251
|
+
network: false,
|
|
252
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'owner-config',
|
|
256
|
+
spec: local('./owner-config-banner.mjs'),
|
|
257
|
+
fn: 'checkOwnerConfig',
|
|
258
|
+
network: false,
|
|
259
|
+
// Host-wide `owner.yaml`, deliberately NOT repo-scoped — no repoRoot arg.
|
|
260
|
+
args: () => ({}),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: 'moc-staleness',
|
|
264
|
+
spec: local('./moc-staleness-banner.mjs'),
|
|
265
|
+
fn: 'checkMocStaleness',
|
|
266
|
+
network: false,
|
|
267
|
+
args: ({ repoRoot, config }) => ({ repoRoot, config }),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'context-coverage',
|
|
271
|
+
spec: local('./context-coverage-banner.mjs'),
|
|
272
|
+
fn: 'checkContextCoverage',
|
|
273
|
+
network: false,
|
|
274
|
+
args: ({ repoRoot, config }) => ({ repoRoot, config }),
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'claude-md-budget-lint',
|
|
278
|
+
spec: local('./claude-md-budget-lint.mjs'),
|
|
279
|
+
fn: 'checkClaudeMdBudgetLint',
|
|
280
|
+
network: false,
|
|
281
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: 'tests-src-ratio',
|
|
285
|
+
spec: local('./tests-src-ratio.mjs'),
|
|
286
|
+
fn: 'checkTestsSrcRatio',
|
|
287
|
+
network: false,
|
|
288
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: 'project-hygiene',
|
|
292
|
+
spec: local('./project-hygiene.mjs'),
|
|
293
|
+
fn: 'checkProjectHygiene',
|
|
294
|
+
network: false,
|
|
295
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: 'mirror-issues',
|
|
299
|
+
spec: local('./mirror-issues-banner.mjs'),
|
|
300
|
+
fn: 'checkMirrorIssues',
|
|
301
|
+
network: true,
|
|
302
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'git-config-drift',
|
|
306
|
+
spec: local('./git-config-drift.mjs'),
|
|
307
|
+
fn: 'checkGitConfigDrift',
|
|
308
|
+
network: false,
|
|
309
|
+
args: ({ repoRoot, env }) => ({ repoRoot, env }),
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Helpers
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The installed plugin's own version, for `bootstrap-lock-freshness`'s
|
|
319
|
+
* version-drift comparison. Read from THIS plugin's package.json (two levels up
|
|
320
|
+
* from `scripts/lib/`), never from the target repo — the probe compares the
|
|
321
|
+
* lock's recorded plugin version against the plugin that is running now.
|
|
322
|
+
*
|
|
323
|
+
* @returns {string|undefined} — undefined when unreadable (the probe then
|
|
324
|
+
* reports `current=unknown` rather than a fabricated version).
|
|
325
|
+
*/
|
|
326
|
+
function pluginVersion() {
|
|
327
|
+
try {
|
|
328
|
+
const pkg = path.resolve(import.meta.dirname, '..', '..', 'package.json');
|
|
329
|
+
const parsed = JSON.parse(readFileSync(pkg, 'utf8'));
|
|
330
|
+
return typeof parsed.version === 'string' ? parsed.version : undefined;
|
|
331
|
+
} catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Race `promise` against a deadline.
|
|
338
|
+
*
|
|
339
|
+
* The loser is neutralised (`.catch`) before the race so a late rejection can
|
|
340
|
+
* never surface as an unhandled rejection and kill an exit-0 hook.
|
|
341
|
+
*
|
|
342
|
+
* @template T
|
|
343
|
+
* @param {Promise<T>} promise
|
|
344
|
+
* @param {number} ms — remaining budget; `<= 0` times out immediately.
|
|
345
|
+
* @returns {Promise<T|symbol>} — resolves to {@link TIMED_OUT} on expiry.
|
|
346
|
+
*/
|
|
347
|
+
async function withDeadline(promise, ms) {
|
|
348
|
+
const settled = promise.catch((err) => ({ __probeError: err }));
|
|
349
|
+
if (ms <= 0) return TIMED_OUT;
|
|
350
|
+
let timer;
|
|
351
|
+
const expiry = new Promise((resolve) => {
|
|
352
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
353
|
+
// Never hold the event loop open for the timer alone.
|
|
354
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
return await Promise.race([settled, expiry]);
|
|
358
|
+
} finally {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Normalise a probe result's severity.
|
|
365
|
+
*
|
|
366
|
+
* `null` is the family's universal "clean, say nothing" return. Everything else
|
|
367
|
+
* is judged by its `severity` field — including the THREE-state probes
|
|
368
|
+
* (`mirror-issues`, `git-config-drift`, `ci-status`) whose `degraded` result
|
|
369
|
+
* means "state unknown" and must NEVER be read as clean.
|
|
370
|
+
*
|
|
371
|
+
* @param {*} result
|
|
372
|
+
* @param {{severityOf?: (r: *) => string}} probe
|
|
373
|
+
* @returns {'ok'|'warn'|'alert'}
|
|
374
|
+
*/
|
|
375
|
+
function severityOf(result, probe) {
|
|
376
|
+
if (typeof probe.severityOf === 'function') {
|
|
377
|
+
const s = probe.severityOf(result);
|
|
378
|
+
return s === 'alert' || s === 'warn' ? s : 'ok';
|
|
379
|
+
}
|
|
380
|
+
if (result === null || result === undefined) return 'ok';
|
|
381
|
+
const s = result.severity;
|
|
382
|
+
if (s === 'alert') return 'alert';
|
|
383
|
+
if (s === 'warn') return 'warn';
|
|
384
|
+
return 'ok';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Default banner renderer: the probe's own `message`, but only when it actually
|
|
389
|
+
* found something. A probe that returns `{severity: 'ok'|'info'}` is silent.
|
|
390
|
+
*
|
|
391
|
+
* @param {*} result
|
|
392
|
+
* @param {string} severity
|
|
393
|
+
* @returns {string|null}
|
|
394
|
+
*/
|
|
395
|
+
function defaultRender(result, severity) {
|
|
396
|
+
if (severity === 'ok') return null;
|
|
397
|
+
const msg = result?.message;
|
|
398
|
+
return typeof msg === 'string' && msg.length > 0 ? msg : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Load Session Config for the repo, fail-open to `{}`.
|
|
403
|
+
* @param {string} repoRoot
|
|
404
|
+
* @returns {Promise<object>}
|
|
405
|
+
*/
|
|
406
|
+
async function loadConfig(repoRoot) {
|
|
407
|
+
try {
|
|
408
|
+
const { readConfigFile, parseSessionConfig } = await import('./config.mjs');
|
|
409
|
+
return parseSessionConfig(await readConfigFile(repoRoot)) ?? {};
|
|
410
|
+
} catch {
|
|
411
|
+
return {};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Runner
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Run the Phase 4 probe family and record the run.
|
|
421
|
+
*
|
|
422
|
+
* Never rejects and never throws: every failure mode of every probe is folded
|
|
423
|
+
* into that probe's `outcome`.
|
|
424
|
+
*
|
|
425
|
+
* @param {object} [opts]
|
|
426
|
+
* @param {string} opts.repoRoot — repo the probes measure AND the repo whose
|
|
427
|
+
* `.orchestrator/metrics/events.jsonl` receives the telemetry record. Pinning
|
|
428
|
+
* this is what keeps a test out of the real ledger.
|
|
429
|
+
* @param {object} [opts.config] — parsed Session Config; loaded from `repoRoot`
|
|
430
|
+
* when omitted.
|
|
431
|
+
* @param {object} [opts.env=process.env] — environment passed to env-reading
|
|
432
|
+
* probes and consulted for `SO_PROBES_INCLUDE_NETWORK`.
|
|
433
|
+
* @param {number} [opts.timeoutMs=PROBE_BUDGET_MS] — wall-clock budget.
|
|
434
|
+
* @param {object} [deps] — test seams.
|
|
435
|
+
* @param {Array<object>} [deps.probes=PROBES] — probe registry override.
|
|
436
|
+
* @param {(type: string, payload: object, opts: object) => Promise<void>} [deps.emit=emitEvent]
|
|
437
|
+
* @returns {Promise<{bannerLines: string[], results: Array<{id: string, outcome: string, severity?: string, durationMs: number, reason?: string}>, event: object|null}>}
|
|
438
|
+
*/
|
|
439
|
+
export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
440
|
+
const started = Date.now();
|
|
441
|
+
const {
|
|
442
|
+
repoRoot,
|
|
443
|
+
env = process.env,
|
|
444
|
+
timeoutMs = PROBE_BUDGET_MS,
|
|
445
|
+
} = opts;
|
|
446
|
+
const probes = Array.isArray(deps.probes) ? deps.probes : PROBES;
|
|
447
|
+
const emit = typeof deps.emit === 'function' ? deps.emit : emitEvent;
|
|
448
|
+
|
|
449
|
+
// No repoRoot means every repo-scoped probe would measure the wrong tree and
|
|
450
|
+
// the event would land in whatever `SO_PROJECT_DIR` happens to resolve to.
|
|
451
|
+
// Refusing here is the fail-open answer: report nothing, write nothing.
|
|
452
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
453
|
+
return { bannerLines: [], results: [], event: null };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const config = opts.config ?? (await loadConfig(repoRoot));
|
|
457
|
+
|
|
458
|
+
// Network probes: excluded by default, and the exclusion is RECORDED.
|
|
459
|
+
//
|
|
460
|
+
// `ci-status` and `mirror-issues` spawn `glab`/`gh` against a remote with an
|
|
461
|
+
// 8s CLI timeout of their own — a single one can exceed the hook's entire 5s
|
|
462
|
+
// `hooks.json` budget. Measured warm here on 2026-08-23: 520 ms and 498 ms,
|
|
463
|
+
// and that is the BEST case (warm CLI, live network, authenticated). Paying
|
|
464
|
+
// that on every session start of every repo buys a signal the operator can
|
|
465
|
+
// get on demand from `/session`, so the default is off.
|
|
466
|
+
//
|
|
467
|
+
// What is NOT acceptable is dropping them silently — that reproduces the
|
|
468
|
+
// exact defect this module repairs. They appear in every run's telemetry as
|
|
469
|
+
// `outcome: 'skipped'`, `reason: 'network-probe-opt-in'`, so "were they run?"
|
|
470
|
+
// stays an answerable question.
|
|
471
|
+
const includeNetwork = env?.SO_PROBES_INCLUDE_NETWORK === '1';
|
|
472
|
+
|
|
473
|
+
const ctx = { repoRoot, config, env };
|
|
474
|
+
const deadline = started + Math.max(0, Number(timeoutMs) || 0);
|
|
475
|
+
|
|
476
|
+
/** @type {Map<string, object>} */
|
|
477
|
+
const byId = new Map();
|
|
478
|
+
|
|
479
|
+
await Promise.all(
|
|
480
|
+
probes.map(async (probe) => {
|
|
481
|
+
const t0 = Date.now();
|
|
482
|
+
const record = (outcome, extra = {}) => {
|
|
483
|
+
byId.set(probe.id, {
|
|
484
|
+
id: probe.id,
|
|
485
|
+
outcome,
|
|
486
|
+
durationMs: Date.now() - t0,
|
|
487
|
+
...extra,
|
|
488
|
+
});
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (probe.network && !includeNetwork) {
|
|
492
|
+
record('skipped', { reason: 'network-probe-opt-in' });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let skipReason;
|
|
497
|
+
try {
|
|
498
|
+
skipReason = typeof probe.precondition === 'function' ? probe.precondition(ctx) : null;
|
|
499
|
+
} catch {
|
|
500
|
+
// A precondition that throws is a defect in the precondition, not a
|
|
501
|
+
// finding about the repo — treat it as "cannot decide", run nothing.
|
|
502
|
+
record('error', { reason: 'precondition-threw' });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (typeof skipReason === 'string' && skipReason.length > 0) {
|
|
506
|
+
record('skipped', { reason: skipReason });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The whole invocation — import included — is inside the race, because a
|
|
511
|
+
// pre-#369-style absent module and a hung probe are both "did not
|
|
512
|
+
// deliver" and both must resolve to an outcome rather than to a throw.
|
|
513
|
+
const invocation = (async () => {
|
|
514
|
+
const mod = await import(probe.spec);
|
|
515
|
+
const fn = mod?.[probe.fn];
|
|
516
|
+
if (typeof fn !== 'function') {
|
|
517
|
+
return { __probeError: new Error(`export ${probe.fn} missing`), __absent: true };
|
|
518
|
+
}
|
|
519
|
+
return { __probeResult: await fn(probe.args(ctx)) };
|
|
520
|
+
})();
|
|
521
|
+
|
|
522
|
+
const raced = await withDeadline(invocation, deadline - Date.now());
|
|
523
|
+
|
|
524
|
+
if (raced === TIMED_OUT) {
|
|
525
|
+
record('timeout', { reason: 'budget-exceeded' });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (raced && raced.__probeError) {
|
|
529
|
+
// A missing module is the documented "pre-#N plugin install" case:
|
|
530
|
+
// SKILL.md says skip silently. Made visible as a skip, not an error.
|
|
531
|
+
if (raced.__absent || raced.__probeError?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
532
|
+
record('skipped', { reason: 'module-absent' });
|
|
533
|
+
} else {
|
|
534
|
+
record('error', { reason: String(raced.__probeError?.message ?? raced.__probeError).slice(0, 200) });
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const result = raced?.__probeResult;
|
|
540
|
+
const severity = severityOf(result, probe);
|
|
541
|
+
const line = typeof probe.render === 'function'
|
|
542
|
+
? probe.render(result)
|
|
543
|
+
: defaultRender(result, severity);
|
|
544
|
+
record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
|
|
545
|
+
severity,
|
|
546
|
+
...(line ? { line } : {}),
|
|
547
|
+
});
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// Registry order, never completion order — a run must be reproducible.
|
|
552
|
+
const results = [];
|
|
553
|
+
const bannerLines = [];
|
|
554
|
+
for (const probe of probes) {
|
|
555
|
+
const r = byId.get(probe.id);
|
|
556
|
+
if (!r) continue;
|
|
557
|
+
if (r.line) bannerLines.push(r.line);
|
|
558
|
+
const { line: _line, ...rest } = r;
|
|
559
|
+
results.push(rest);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const count = (pred) => results.filter(pred).length;
|
|
563
|
+
const ran = count((r) => r.outcome.startsWith('ran-'));
|
|
564
|
+
const warned = count((r) => r.outcome === 'ran-warn' || r.outcome === 'ran-alert');
|
|
565
|
+
const skipped = count((r) => r.outcome === 'skipped');
|
|
566
|
+
const errored = count((r) => r.outcome === 'error');
|
|
567
|
+
const timedOut = count((r) => r.outcome === 'timeout');
|
|
568
|
+
|
|
569
|
+
// One aggregate line, and only when something actually failed to deliver — a
|
|
570
|
+
// banner that fires on every start teaches the operator to ignore banners
|
|
571
|
+
// (`.claude/rules/host-resources.md` § HR-101).
|
|
572
|
+
if (errored + timedOut > 0) {
|
|
573
|
+
const parts = [];
|
|
574
|
+
if (errored > 0) parts.push(`${errored} errored`);
|
|
575
|
+
if (timedOut > 0) parts.push(`${timedOut} timed out`);
|
|
576
|
+
bannerLines.push(
|
|
577
|
+
`⚠ session-start probes: ${parts.join(', ')} — see orchestrator.probes.completed in .orchestrator/metrics/events.jsonl.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const event = {
|
|
582
|
+
total: probes.length,
|
|
583
|
+
ran,
|
|
584
|
+
warned,
|
|
585
|
+
skipped,
|
|
586
|
+
errored,
|
|
587
|
+
timed_out: timedOut,
|
|
588
|
+
duration_ms: Date.now() - started,
|
|
589
|
+
// `reason` travels. Dropping it here was the module's own rule broken at its
|
|
590
|
+
// own boundary: `module-absent` is the ONE skip reason that means a probe is
|
|
591
|
+
// permanently dead, and without it the ledger cannot tell that apart from
|
|
592
|
+
// `network-probe-opt-in`, which is the intended default. "Measured, then
|
|
593
|
+
// discarded" is worse than "not measured" — the value existed.
|
|
594
|
+
probes: results.map((r) => ({
|
|
595
|
+
id: r.id,
|
|
596
|
+
outcome: r.outcome,
|
|
597
|
+
...(typeof r.reason === 'string' && r.reason.length > 0 ? { reason: r.reason } : {}),
|
|
598
|
+
})),
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Telemetry is best-effort like every other side effect here: a ledger that
|
|
602
|
+
// cannot be written must not cost the operator his banners.
|
|
603
|
+
try {
|
|
604
|
+
await emit('orchestrator.probes.completed', event, { repoRoot });
|
|
605
|
+
} catch { /* never block the caller */ }
|
|
606
|
+
|
|
607
|
+
return { bannerLines, results, event };
|
|
608
|
+
}
|