session-orchestrator 5.3.0 → 5.5.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/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -29,68 +29,15 @@
|
|
|
29
29
|
|
|
30
30
|
> **#701.2 DOC NOTE — `completed_at >= started_at` guard:** This invariant is enforced mechanically by `scripts/emit-session.mjs`. The writer applies `clampTimestampsMonotonic()` (from `scripts/lib/session-schema/timestamps.mjs`) before `validateSession()`, clamping any inversion of `completed_at < started_at` to `started_at` and recording forensics in `_clamped: true` / `_original_completed_at`. Previously-inverted entries (e.g. `main-2026-06-21-session-4`) are already corrected. **No per-session coordinator action is needed** — the writer enforces the invariant at write time. Do not add defensive clamping logic here; the canonical guard lives in `emit-session.mjs`.
|
|
31
31
|
|
|
32
|
-
1a. **Token Rollup (#644,
|
|
32
|
+
1a. **Token Rollup (#644, #1244 — mechanical since #1436)** — no coordinator action. `scripts/emit-session.mjs` (step 2) runs `rollupSessionTokens()` itself whenever the incoming record carries no `total_tokens`: it resolves the session's raw UUID from `.orchestrator/current-session.json` (adopted only when its `semantic_session_id` equals the record's `session_id` and no process-local session id contradicts it), joins the sibling `subagents.jsonl` on `parent_session_id`, and fills `total_tokens`, `total_token_input`, `total_token_output`, the three cache buckets, `total_cost_usd`, `subagents_with_tokens`, `matched_records`, `cost_records_priced`, `cost_records_total` and `_token_schema` for every key the record does not already carry — plus `raw_session_id`. No matching records, an unreadable ledger or a foreign marker omits the fields with a WARN on stderr, never a fabricated `0`. An explicit key on `$METRICS_ENTRY` still wins. The same UUID also backs `session_start_ref` when STATE.md has none (the `head_sha` of the session's own `orchestrator.session.started` event).
|
|
33
33
|
|
|
34
|
-
**
|
|
35
|
-
|
|
36
|
-
**Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: `null` means "at least one agent ran on a model the price table does not know", never "$0".
|
|
34
|
+
**Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: it is omitted whenever `cost_records_priced < cost_records_total` (#1475) — at least one cost-relevant subagent record could not be priced (a model the price table does not know, a token-bearing turn naming no model, a transcript that yielded no tokens, e.g. one over the hook's size cap, or an agent with a start record whose stop found no transcript of its own, mostly `workflow-subagent`, or an agent known only from `schema_version` 1 records — #1487 Pkt 1) — and also when `cost_records_total` is 0, because there was nothing to price. It is never "$0" for an unknown cost and never a partial sum over the v2 agents the ledger can see. Phantom stops (#939, no start record) are no agents and never count. The two counters are persisted beside it, so a missing cost says whether part of the session was unpriceable or nothing was priceable. Blind spots remain: an agent whose start record fell outside the hook's tail window looks like a phantom, an agent with neither start nor stop record is invisible. A session spanning the 2026-09-09 schema boundary therefore reports no cost (its v1 agents are unpriced candidates since 2026-10-02; before, it was priced over its v2 records only — 4 distinct fleet sessions carried such a partial sum). A subagent whose turns span several models carries a per-model `models_usage` breakdown in `subagents.jsonl` (#1470) and is priced part by part. The emit-session warning names the reason via `match_status` (ledger absent, ledger with no readable records, or records present but none for the own raw UUID); none of these means zero cost.
|
|
37
35
|
|
|
38
36
|
**Provenance (#949):** the rollup sums ONLY records carrying `subagent_transcript_found: true` — the flag the producer sets when it read the subagent's own transcript. Pre-#949 records carry the PARENT transcript's running totals and are excluded, so a session made up entirely of them now reports `null` rather than a fabricated sum (73 historical sessions, 96,148,781 phantom tokens, measured 2026-08-11). Two consequences for readers: totals already written into `sessions.jsonl` before 2026-08-11 were produced by the unfiltered recipe and are a series break, not a trend; and `matched_records` counts start records and phantom stops alike, so it is NOT the denominator for a coverage ratio — use `subagents_with_tokens` against the session's real agent count.
|
|
39
37
|
|
|
40
38
|
**Schema boundary (#1244, 2026-09-09):** from `schema_version: 2` a subagent record's `token_input` is BILLABLE PROMPT VOLUME (uncached + cache_read + cache_creation); v1 records held raw uncached input only and are therefore EXCLUDED from every total and reported as `legacy_v1_records`. Sessions spanning the boundary are a second series break — do not trend across it.
|
|
41
39
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```js
|
|
45
|
-
// Available from scripts/lib/session-token-rollup.mjs
|
|
46
|
-
import { rollupSessionTokens } from '../../scripts/lib/session-token-rollup.mjs';
|
|
47
|
-
|
|
48
|
-
const rollup = rollupSessionTokens({ parentSessionId: SESSION_UUID });
|
|
49
|
-
// rollup: { total_token_input, total_token_output, subagents_with_tokens, matched_records,
|
|
50
|
-
// total_token_input_uncached, total_token_cache_read, total_token_cache_creation,
|
|
51
|
-
// total_cost_usd, cost_records_priced, cost_records_total, legacy_v1_records,
|
|
52
|
-
// _token_schema }
|
|
53
|
-
// Merge into the record — every field below is optional in the session schema (additive).
|
|
54
|
-
metricsEntry.total_token_input = rollup.total_token_input; // number | null
|
|
55
|
-
metricsEntry.total_token_output = rollup.total_token_output; // number | null
|
|
56
|
-
metricsEntry.subagents_with_tokens = rollup.subagents_with_tokens; // number (0 when no coverage)
|
|
57
|
-
metricsEntry.total_token_input_uncached = rollup.total_token_input_uncached; // number | null
|
|
58
|
-
metricsEntry.total_token_cache_read = rollup.total_token_cache_read; // number | null
|
|
59
|
-
metricsEntry.total_token_cache_creation = rollup.total_token_cache_creation; // number | null
|
|
60
|
-
metricsEntry.total_cost_usd = rollup.total_cost_usd; // number | null (null = unknown model)
|
|
61
|
-
metricsEntry._token_schema = rollup._token_schema; // 2
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Report `cost_records_priced / cost_records_total` and `legacy_v1_records` in the session summary when either is non-zero — an unpriced or excluded remainder is what makes a cost figure honest.
|
|
65
|
-
|
|
66
|
-
Or, from a bash context, call the rollup via a helper node invocation and `jq`-merge the fields into `$METRICS_ENTRY` before step 2:
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
ROLLUP_JSON=$(node -e "
|
|
70
|
-
import('$(dirname "$0")/../scripts/lib/session-token-rollup.mjs').then(m => {
|
|
71
|
-
const r = m.rollupSessionTokens({ parentSessionId: process.env.SESSION_UUID });
|
|
72
|
-
process.stdout.write(JSON.stringify(r));
|
|
73
|
-
});
|
|
74
|
-
" 2>/dev/null) || ROLLUP_JSON='{}'
|
|
75
|
-
|
|
76
|
-
# Merge token fields into METRICS_ENTRY (null for fields absent from rollup)
|
|
77
|
-
METRICS_ENTRY=$(printf '%s' "$METRICS_ENTRY" | jq \
|
|
78
|
-
--argjson r "${ROLLUP_JSON:-{}}" \
|
|
79
|
-
'. + {
|
|
80
|
-
total_token_input: ($r.total_token_input // null),
|
|
81
|
-
total_token_output: ($r.total_token_output // null),
|
|
82
|
-
subagents_with_tokens: ($r.subagents_with_tokens // 0),
|
|
83
|
-
total_token_input_uncached: ($r.total_token_input_uncached // null),
|
|
84
|
-
total_token_cache_read: ($r.total_token_cache_read // null),
|
|
85
|
-
total_token_cache_creation: ($r.total_token_cache_creation // null),
|
|
86
|
-
total_cost_usd: ($r.total_cost_usd // null),
|
|
87
|
-
_token_schema: ($r._token_schema // 2)
|
|
88
|
-
}')
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**If the rollup call fails** (e.g., `subagents.jsonl` absent, parse error), set the numeric totals to `null` / `0` and continue — the rollup is non-blocking. A session without token data still writes cleanly.
|
|
92
|
-
|
|
93
|
-
2. Append the prepared JSONL entry (from Phase 1.7, now including token fields from step 1a) via the validating writer `scripts/emit-session.mjs` (issue #249):
|
|
40
|
+
2. Append the prepared JSONL entry (from Phase 1.7; the writer itself adds the token fields — step 1a) via the validating writer `scripts/emit-session.mjs` (issue #249):
|
|
94
41
|
```bash
|
|
95
42
|
printf '%s' "$METRICS_ENTRY" | node "$PLUGIN_ROOT/scripts/emit-session.mjs" --file .orchestrator/metrics/sessions.jsonl
|
|
96
43
|
EMIT_EXIT=$?
|
|
@@ -135,11 +82,36 @@
|
|
|
135
82
|
```bash
|
|
136
83
|
VM_ENABLED=$(echo "$CONFIG" | jq -r '."vault-integration".enabled // false')
|
|
137
84
|
VM_MODE=$(echo "$CONFIG" | jq -r '."vault-integration".mode // "warn"')
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
85
|
+
VM_HOST_OVERRIDE=$(echo "$CONFIG" | jq -r '."vault-integration"."host-override" // empty')
|
|
86
|
+
|
|
87
|
+
if [[ -n "$VM_HOST_OVERRIDE" && ( "$VM_ENABLED" != "true" || "$VM_MODE" == "off" ) ]]; then
|
|
88
|
+
# SO#1448: this host switched the mirror off on purpose (env
|
|
89
|
+
# SO_VAULT_INTEGRATION or owner.yaml `vault-integration:`). Say so, instead of
|
|
90
|
+
# running into vault-mirror's exit 2 (missing-vault-dir) and a strict block.
|
|
91
|
+
echo "vault-mirror: bewusst aus auf diesem Host ($VM_HOST_OVERRIDE)"
|
|
92
|
+
elif [[ "$VM_ENABLED" == "true" && "$VM_MODE" != "off" ]]; then
|
|
93
|
+
# Resolve vault directory: config field (host-resolved, see vault-dir-source)
|
|
94
|
+
# takes precedence; $VAULT_DIR is only the bash fallback when it is empty.
|
|
141
95
|
VM_DIR=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-dir" // empty')
|
|
142
|
-
|
|
96
|
+
VM_DIR_SOURCE=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-dir-source" // "committed"')
|
|
97
|
+
if [[ -z "$VM_DIR" && -n "${VAULT_DIR:-}" ]]; then
|
|
98
|
+
VM_DIR="$VAULT_DIR"
|
|
99
|
+
VM_DIR_SOURCE="env VAULT_DIR"
|
|
100
|
+
fi
|
|
101
|
+
# SO#1490: display form for echo lines only — the home dir becomes `~`
|
|
102
|
+
# through the ONE redactor (`redactHomeDir`, scripts/lib/common.mjs,
|
|
103
|
+
# path-boundary aware: `/Users/bob` never rewrites `/Users/bobby`). The
|
|
104
|
+
# commands below still get the real value. VM_DIR has no producer to emit
|
|
105
|
+
# a display path, so one helper serves every echo; if node cannot load the
|
|
106
|
+
# module it falls back to `…/<basename>` (vault-mirror's #1479 form),
|
|
107
|
+
# never to the raw path. The `--` is load-bearing: without it a value that
|
|
108
|
+
# starts with `-` (vault-dir comes from the committed CLAUDE.md / AGENTS.md) is parsed as
|
|
109
|
+
# a node option, and `--import=data:…` would execute code at /close.
|
|
110
|
+
so_tilde() {
|
|
111
|
+
node --input-type=module -e "import { redactHomeDir } from '$PLUGIN_ROOT/scripts/lib/common.mjs'; process.stdout.write(redactHomeDir(process.argv[1]))" -- "$1" 2>/dev/null \
|
|
112
|
+
|| printf '…/%s' "${1##*/}"
|
|
113
|
+
}
|
|
114
|
+
echo "vault-dir=$(so_tilde "$VM_DIR") (Quelle: $VM_DIR_SOURCE)"
|
|
143
115
|
|
|
144
116
|
# Quality-gate thresholds (PRD F1.2). Defaults match
|
|
145
117
|
# scripts/vault-mirror.mjs (400 chars / 0.5 confidence). The nested key
|
|
@@ -149,9 +121,14 @@
|
|
|
149
121
|
VM_QUALITY_CONFIDENCE=$(echo "$CONFIG" | jq -r '."vault-mirror".quality."min-confidence" // 0.5')
|
|
150
122
|
VM_VAULT_NAME=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-name" // empty')
|
|
151
123
|
|
|
124
|
+
# #1503: name the repo being mirrored and pass the ledger ABSOLUTE. The
|
|
125
|
+
# namespace is that repo's identity, never the cwd's; a --source that lies in
|
|
126
|
+
# a different repo than --repo-root is refused (exit 2, source-repo-mismatch)
|
|
127
|
+
# instead of being written over another project's notes.
|
|
152
128
|
VM_OUTPUT=$(node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
|
|
153
129
|
--vault-dir "$VM_DIR" \
|
|
154
|
-
--
|
|
130
|
+
--repo-root "$PWD" \
|
|
131
|
+
--source "$PWD/.orchestrator/metrics/sessions.jsonl" \
|
|
155
132
|
--kind session \
|
|
156
133
|
--session-id "$SESSION_ID" \
|
|
157
134
|
${VM_VAULT_NAME:+--vault-name "$VM_VAULT_NAME"} \
|
|
@@ -159,9 +136,11 @@
|
|
|
159
136
|
--quality-min-confidence "$VM_QUALITY_CONFIDENCE" 2>&1)
|
|
160
137
|
VM_EXIT=$?
|
|
161
138
|
|
|
162
|
-
# Surface script output so user can see skipped-handwritten results
|
|
139
|
+
# Surface script output so user can see skipped-handwritten results.
|
|
140
|
+
# `printf '%s\n'`, not `echo`: zsh's echo expands the `\n` escapes inside
|
|
141
|
+
# JSON strings and would split an action line in two.
|
|
163
142
|
if [[ -n "$VM_OUTPUT" ]]; then
|
|
164
|
-
|
|
143
|
+
printf '%s\n' "$VM_OUTPUT"
|
|
165
144
|
fi
|
|
166
145
|
|
|
167
146
|
if [[ $VM_EXIT -ne 0 ]]; then
|
|
@@ -174,18 +153,31 @@
|
|
|
174
153
|
echo "WARNING: vault-mirror exited $VM_EXIT — session metrics were NOT mirrored to the vault. Set vault-integration.mode: strict to block on this error."
|
|
175
154
|
fi
|
|
176
155
|
else
|
|
177
|
-
# Parse the
|
|
178
|
-
|
|
156
|
+
# Parse the action lines. stdout carries one JSON object per entry, but
|
|
157
|
+
# this capture also holds stderr (`2>&1`): `SKIP …` / `WARN …` lines,
|
|
158
|
+
# routine since #1503. A plain `jq` stops at the first non-JSON line
|
|
159
|
+
# (exit 5) and silently loses every action after it, so read raw lines
|
|
160
|
+
# and keep only the ones that parse as JSON objects.
|
|
161
|
+
vm_actions() { printf '%s\n' "$VM_OUTPUT" | jq -R -r "fromjson? | objects | $1" 2>/dev/null; }
|
|
162
|
+
|
|
163
|
+
VM_DEST=$(vm_actions 'select(.action == "created" or .action == "updated") | .path' | head -1)
|
|
179
164
|
if [[ -n "$VM_DEST" ]]; then
|
|
180
165
|
echo "Mirrored session summary to $VM_DEST"
|
|
181
166
|
fi
|
|
182
167
|
|
|
183
168
|
# Quality gate summary (PRD F1.2): count entries skipped because they
|
|
184
169
|
# failed the quality filter, so the operator can tune thresholds.
|
|
185
|
-
VM_QUALITY_SKIP=$(
|
|
170
|
+
VM_QUALITY_SKIP=$(vm_actions 'select(.action == "skipped-quality-low") | .action' | wc -l | tr -d ' ')
|
|
186
171
|
if [[ "${VM_QUALITY_SKIP:-0}" -gt 0 ]]; then
|
|
187
172
|
echo "vault-mirror: ${VM_QUALITY_SKIP} entry/entries skipped by quality gate (set vault-mirror.quality.min-narrative-chars / min-confidence to tune)"
|
|
188
173
|
fi
|
|
174
|
+
|
|
175
|
+
# #1503: session notes left untouched because they were rendered from a
|
|
176
|
+
# record that is not in this ledger (another repo's, or an id collision).
|
|
177
|
+
VM_FOREIGN_SKIP=$(vm_actions 'select(.action == "skipped-foreign-owner") | .action' | wc -l | tr -d ' ')
|
|
178
|
+
if [[ "${VM_FOREIGN_SKIP:-0}" -gt 0 ]]; then
|
|
179
|
+
echo "vault-mirror: ${VM_FOREIGN_SKIP} note(s) left untouched — owned by a session record outside this ledger (skipped-foreign-owner; see the SKIP lines above)"
|
|
180
|
+
fi
|
|
189
181
|
fi
|
|
190
182
|
|
|
191
183
|
# ── Durable Narrative Mirror (#675) ────────────────────────────────────
|
|
@@ -216,7 +208,7 @@
|
|
|
216
208
|
|
|
217
209
|
if [[ $NM_EXIT -ne 0 ]]; then
|
|
218
210
|
if [[ "$VM_MODE" == "strict" ]]; then
|
|
219
|
-
echo "ERROR: narrative-mirror failed (exit $NM_EXIT) — session close blocked (vault-integration.mode=strict): $NM_OUTPUT"
|
|
211
|
+
echo "ERROR: narrative-mirror failed (exit $NM_EXIT) — session close blocked (vault-integration.mode=strict): $(so_tilde "$NM_OUTPUT")"
|
|
220
212
|
echo "Fix the narrative mirror issue or set vault-integration.mode: warn to downgrade to a warning."
|
|
221
213
|
exit 1
|
|
222
214
|
else
|
|
@@ -226,7 +218,9 @@
|
|
|
226
218
|
else
|
|
227
219
|
# Surface the JSON result so the operator can see skipped-* / written outcomes.
|
|
228
220
|
NM_ACTION=$(echo "$NM_OUTPUT" | jq -r '.action // empty' 2>/dev/null)
|
|
221
|
+
# `.path` is absolute (under the vault, i.e. the home dir) — display form only.
|
|
229
222
|
NM_PATH=$(echo "$NM_OUTPUT" | jq -r '.path // empty' 2>/dev/null)
|
|
223
|
+
[[ -n "$NM_PATH" ]] && NM_PATH=$(so_tilde "$NM_PATH")
|
|
230
224
|
if [[ "$NM_ACTION" == "written" && -n "$NM_PATH" ]]; then
|
|
231
225
|
echo "Mirrored durable session narrative to $NM_PATH"
|
|
232
226
|
elif [[ -n "$NM_ACTION" ]]; then
|
|
@@ -242,9 +236,12 @@
|
|
|
242
236
|
|
|
243
237
|
| `enabled` | `mode` | Result |
|
|
244
238
|
|-----------|---------|--------|
|
|
239
|
+
| `false` via host override (`host-override` set) | any | Skip; print `vault-mirror: bewusst aus auf diesem Host (<quelle>)` — never blocks |
|
|
245
240
|
| `false` or missing | any | Skip entirely — no-op, no output |
|
|
246
241
|
| `true` | `off` | Skip entirely — no-op, no output |
|
|
247
242
|
| `true` | `warn` | Run mirror; on failure surface a warning but do NOT block close |
|
|
248
243
|
| `true` | `strict` | Run mirror; on failure block session close with an error message |
|
|
249
244
|
|
|
250
|
-
> **
|
|
245
|
+
> **Host-local overrides.** `enabled`/`mode` above are the values AFTER the host switch: env `SO_VAULT_INTEGRATION=off|warn|strict` > owner.yaml `vault-integration: { enabled: false | mode: … }` > committed. The host may only lower the committed level, never raise it; `."vault-integration"."host-override"` names the tier that lowered it (`env:SO_VAULT_INTEGRATION`, `owner.yaml`) and is `null` otherwise. `."vault-integration"."vault-dir-source"` (`env`, `match`, `owner`, `committed`) says where the vault path came from. See `docs/session-config-reference.md` § Vault Integration.
|
|
246
|
+
|
|
247
|
+
> **Hand-written note protection:** `vault-mirror.mjs` checks for a `_generator: session-orchestrator-vault-mirror@1` marker before overwriting any existing file. When it skips an existing hand-written note it emits a JSON line `{"action":"skipped-handwritten","path":"<path>","kind":"<kind>","id":"<id>"}` — the step above surfaces this output so the user can see the result. Action names: `created`, `updated`, `skipped-noop`, `skipped-handwritten`, `skipped-collision-resolved`, `skipped-foreign-owner` (sessions: the note at the target path was rendered from a record outside this ledger — `source-record` mismatch — or names a different session id; file left untouched, line carries a `reason`, counted by the step above, #1503), `skipped-invalid` (entry failed required-field validation, or the mapper crashed rendering an otherwise-parseable record — the latter case carries `reason: "mapper-crash"`, #718), `skipped-quality-low` (entry failed quality gate — PRD F1.2; line carries a `reason` field).
|
|
@@ -140,7 +140,6 @@ Extract these fields for planning:
|
|
|
140
140
|
- **Effective sizing**: use historical sizing data to inform Step 3 complexity scoring
|
|
141
141
|
- **Recurring issues**: pre-populate risk mitigation with known issue patterns
|
|
142
142
|
- **Scope guidance**: validate planned scope against historical session capacity
|
|
143
|
-
- **Over-delivery sizing (#730/H4)**: read the over_delivery_ratio of recent same-session_type waves — from `effective-sizing` learnings if present, else directly from the last ~5 sessions.jsonl records' `waves[].over_delivery_ratio` (skip records lacking the field — pre-#730; also skip Discovery/Finalization waves, whose planned set is empty by design). If the median ratio R > 1.3, the fleet historically under-briefs file scope: inflate the Step 3 "Files to change" estimate by R before scoring the complexity tier; note it under Project Intelligence Applied.
|
|
144
143
|
|
|
145
144
|
For each agreed task/issue:
|
|
146
145
|
1. Read the VCS issue description and acceptance criteria
|
|
@@ -319,8 +318,6 @@ Score the session scope to determine optimal agent counts per wave. Skip only fo
|
|
|
319
318
|
|
|
320
319
|
> **Cross-module scope** counts top-level source directories (e.g., `src/auth/`, `src/api/`, `lib/utils/`). Nested subdirectories under the same parent count as one directory. Non-source directories (docs, config, scripts) don't count unless they contain modified production code.
|
|
321
320
|
|
|
322
|
-
> **Over-delivery adjustment (#730/H4):** when Step 0.5 surfaced a historical over-delivery ratio R > 1.3 for this session_type, score the "Files to change" row against ceil(briefed_files × R) rather than the raw briefed count — agents historically deliver R× the briefed scope, so the raw count under-sizes the wave.
|
|
323
|
-
|
|
324
321
|
### Complexity Tiers
|
|
325
322
|
|
|
326
323
|
| Tier | Score | Description |
|
|
@@ -222,7 +222,7 @@ Group issues by:
|
|
|
222
222
|
|
|
223
223
|
## Phase 4: SSOT & Environment Check
|
|
224
224
|
|
|
225
|
-
> Always runs; every finding is a NON-BLOCKING banner in the Session Overview (never a gate — the Full Gate is the Quality wave's job). Covers SSOT freshness, the Baseline quality commands (resolved `.orchestrator/policy/quality-gates.json` → Session Config → defaults, each availability-checked with `command -v`), Pencil design status, plugin freshness, and the banner-probe family registered in `scripts/lib/session-start-probes.mjs` (bootstrap-lock-freshness, vault-staleness, telemetry-flush-health, ci-status, qg-command-drift, peer-cards, loop-readiness, instruction-budget, maintenance-due, sessions-staleness, sessions-integrity, owner-config, moc-staleness, context-coverage, claude-md-budget-lint, tests:src-ratio, project-hygiene, mirror-issues, git-config-drift —
|
|
225
|
+
> Always runs; every finding is a NON-BLOCKING banner in the Session Overview (never a gate — the Full Gate is the Quality wave's job). Covers SSOT freshness, the Baseline quality commands (resolved `.orchestrator/policy/quality-gates.json` → Session Config → defaults, each availability-checked with `command -v`), Pencil design status, plugin freshness, and the banner-probe family registered in `scripts/lib/session-start-probes.mjs` (bootstrap-lock-freshness, vault-staleness, telemetry-flush-health, ci-status, qg-command-drift, peer-cards, loop-readiness, instruction-budget, maintenance-due, sessions-staleness, sessions-integrity, owner-config, moc-staleness, context-coverage, claude-md-budget-lint, tests:src-ratio, project-hygiene, mirror-issues, git-config-drift, events-retention — 20 total). Per-probe module path, return contract and exact banner wording: [`references/phase-4-ssot-environment-check.md`](references/phase-4-ssot-environment-check.md).
|
|
226
226
|
|
|
227
227
|
## Phase 4.5: Resource Health (v3.1.0)
|
|
228
228
|
|
|
@@ -350,6 +350,12 @@ Invokes `buildLiveSignals` (single SSOT for the signals shape) then `selectMode(
|
|
|
350
350
|
|
|
351
351
|
**See `phase-7-5-mode-selector.md` for full details.**
|
|
352
352
|
|
|
353
|
+
## Phase 7.6: Fleet Check-in (#1462)
|
|
354
|
+
|
|
355
|
+
> Skip silently when `persistence: false` (no ledger for the event). Runs BEFORE the first writing action; everything not stated here is in `skills/_shared/fleet-protocol.md`.
|
|
356
|
+
|
|
357
|
+
Build the check-in JSON (fields per the reference; `session` from `.orchestrator/current-session.json`, `modus` = session type, `kandidaten` = the issue numbers you intend to claim) and pipe it into `node "${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-$PLUGIN_ROOT}}/scripts/fleet-checkin.mjs"` — it validates, stores `checkin/<session_id>.json` (0600) under `~/.config/navigator/` and emits `orchestrator.fleet.checkin`. Its stdout carries `navigator_state` and `navigator_adresse` (the lease's validated `adresse`, else `null`): on `active`, and only when `navigator_adresse` is non-null and reachable via `ListAgents`, send ONE hint to exactly that name, naming the file path — never expect or wait for a reply (CSM-004). Then read `auflagen/<session_id>.json`; if it is still absent at the stdout's `fallback_due_at`, the reference's Standard-Auflagen apply (the CLI computes that deadline: `zeit` plus `CHECKIN_FALLBACK_MIN` while a navigator is active, `zeit` itself — immediately — otherwise). Reading work and edits in the own worktree continue meanwhile.
|
|
358
|
+
|
|
353
359
|
## Phase 8: Structured Presentation & Q&A
|
|
354
360
|
|
|
355
361
|
Read `presentation-format.md` in this skill directory for the output structure, templates, and AskUserQuestion examples.
|
|
@@ -412,7 +418,7 @@ After user alignment:
|
|
|
412
418
|
| `phase-2-5-docs-planning.md` | Phase 2.5 full procedural body — docs-orchestrator config, audience detection, AUQ confirmation, result block emission, non-overlap rules |
|
|
413
419
|
| (inline) Phase 2.6 | Steering docs gate + load — reads `.orchestrator/steering/{product,tech,structure}.md`; silent no-op when directory absent |
|
|
414
420
|
| `references/phase-2-7-portfolio-snapshot.md` | Phase 2.7 full procedural body — GitLab Portfolio Snapshot: dry-run aggregation banner; gated on `gitlab-portfolio.enabled: true` + `vault-integration.enabled: true`; dispatches `scripts/lib/gitlab-portfolio/cli.mjs --dry-run`; 8s timeout; never blocks session-start |
|
|
415
|
-
| `references/phase-4-ssot-environment-check.md` | Phase 4 full procedural body — SSOT freshness, Baseline quality-command resolution + `command -v` availability check, Pencil status, plugin/`bootstrap.lock` freshness, and the
|
|
421
|
+
| `references/phase-4-ssot-environment-check.md` | Phase 4 full procedural body — SSOT freshness, Baseline quality-command resolution + `command -v` availability check, Pencil status, plugin/`bootstrap.lock` freshness, and the 20-probe banner family (module path, return contract and exact wording per probe) |
|
|
416
422
|
| `phase-4-5-resource-health.md` | Phase 4.5 full procedural body — resource probe, adaptive thresholds table, AUQ presentation, session-plan cap handoff |
|
|
417
423
|
| `references/phase-6-5-forced-reads.md` | Phases 6.5.1 + 6.5.2 full procedural bodies — the two forced-read STATE.md continuity slots: `## What Not To Retry` (`readWhatNotToRetry`, #623) and `## Open Questions` (`readOpenQuestions`, #772), both rendered unconditionally and wrapped via `wrapHistorical` from `scripts/lib/historical-guard.mjs` |
|
|
418
424
|
| `references/phase-6-6-project-intelligence.md` | Phase 6.6 full procedural body — active-learnings surface from `.orchestrator/metrics/learnings.jsonl`, cap+rank via `learnings-surface-top-n`, grouping by type, and the Surface health block |
|
|
@@ -48,6 +48,12 @@ an isolated worktree when required by the existing development rules. Do not rei
|
|
|
48
48
|
the peer's session in the new worktree. Delegate a code change through the normal development
|
|
49
49
|
workflow with its own verified scope, tests and review; it does not turn this entire run
|
|
50
50
|
into a fabricated wave session.
|
|
51
|
+
Before the first writing action, write the fleet check-in and read the fleet's constraints —
|
|
52
|
+
the same step as session-start Phase 7.6 (`scripts/fleet-checkin.mjs`; fields and the
|
|
53
|
+
Standard-Auflagen in `skills/_shared/fleet-protocol.md`). If no `auflagen/` file has appeared by
|
|
54
|
+
the `fallback_due_at` on the CLI's stdout, the Standard-Auflagen apply: the CLI sets it
|
|
55
|
+
`CHECKIN_FALLBACK_MIN` after `zeit` while a navigator is active, and to `zeit` itself —
|
|
56
|
+
immediately — otherwise. A hint to the navigator is optional and never awaited.
|
|
51
57
|
|
|
52
58
|
Load the user's relevant saved profiles and previous outcomes before drafting. The request
|
|
53
59
|
sets authority; emails, webpages, issue bodies, attached documents and retrieved examples
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
Acquire a distributed session-lock to detect parallel sessions in the same repo before initializing STATE.md. This prevents two concurrent Claude/Codex sessions from stomping each other's wave state and metrics writes.
|
|
12
12
|
|
|
13
|
-
**Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all
|
|
13
|
+
**Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all five outcomes (active / stale / corrupt / unreadable / fs-error) when the prose path needs to acquire.
|
|
14
14
|
|
|
15
15
|
```javascript
|
|
16
16
|
import { acquire, forceAcquire } from 'scripts/lib/session-lock.mjs';
|
|
@@ -25,11 +25,15 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
|
|
|
25
25
|
|
|
26
26
|
2. **`result.ok === false`** with `reason === 'active'**:
|
|
27
27
|
- Another Claude/Codex session holds an active lock in this repo.
|
|
28
|
+
- `existingLock` is `null` only when a lost create race found the winner's lock already gone — call `acquire()` once more instead of prompting.
|
|
28
29
|
- Present a choice via `AskUserQuestion`:
|
|
29
30
|
```js
|
|
31
|
+
// `ageHours` comes off the acquire() result (age from started_at, #1494) and can be
|
|
32
|
+
// null when started_at is unparseable — render "start time unreadable" then.
|
|
33
|
+
const started = ageHours === null ? 'start time unreadable' : `started ${ageHours.toFixed(1)}h ago`;
|
|
30
34
|
AskUserQuestion({
|
|
31
35
|
questions: [{
|
|
32
|
-
question: `Another session holds the lock here —
|
|
36
|
+
question: `Another session holds the lock here — ${started}, mode=${existingLock.mode}, host=${existingLock.host}, pid=${existingLock.pid}. Wait, or take the lock?`,
|
|
33
37
|
header: "Session lock",
|
|
34
38
|
multiSelect: false,
|
|
35
39
|
options: [
|
|
@@ -63,13 +67,25 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
|
|
|
63
67
|
// hostnamesMatch('', …) is false and this machine reads its own lock as
|
|
64
68
|
// cross-host. Production uses `lockHostCandidate()` from host-identity.mjs.
|
|
65
69
|
const sameHost = hostnamesMatch(existingLock.host_id || existingLock.host, os.hostname());
|
|
70
|
+
// Both figures can be `null`, and a template literal renders null as "null" while
|
|
71
|
+
// Math.round(null) is 0 — "0 minutes ago" for a lock that NEVER heartbeat. A lock
|
|
72
|
+
// without `last_heartbeat` is stale because nothing heartbeats it (#595 sunset), not
|
|
73
|
+
// because its ttl ran out, so the ttl wording branches with it.
|
|
74
|
+
const started = ageHours === null ? 'start time unreadable' : `started ${ageHours.toFixed(1)}h ago`;
|
|
75
|
+
// A NEGATIVE heartbeatAgeMinutes is a heartbeat stamped more than 5 minutes in the
|
|
76
|
+
// future (hand edit, clock stepped back) — not live since #1494, and not "-N minutes ago".
|
|
77
|
+
const heartbeat = heartbeatAgeMinutes === null
|
|
78
|
+
? 'last heartbeat: never (lock has no last_heartbeat), so nothing has kept it alive'
|
|
79
|
+
: heartbeatAgeMinutes < 0
|
|
80
|
+
? `its last heartbeat is stamped ${Math.round(-heartbeatAgeMinutes)} minutes in the future, so nothing proves it alive`
|
|
81
|
+
: `its ttl=${existingLock.ttl_hours}h has expired, and its last heartbeat was ${Math.round(heartbeatAgeMinutes)} minutes ago`;
|
|
66
82
|
AskUserQuestion({
|
|
67
83
|
questions: [{
|
|
68
|
-
question: `A stale session lock is in the way —
|
|
84
|
+
question: `A stale session lock is in the way — ${started} on host=${existingLock.host}${sameHost ? '' : ' (another machine)'}, ${heartbeat}. Reclaim it?`,
|
|
69
85
|
header: "Stale lock",
|
|
70
86
|
multiSelect: false,
|
|
71
87
|
options: [
|
|
72
|
-
{ label: "Reclaim (Recommended)", description: "Overwrites the stale lock and continues, because its time-to-live
|
|
88
|
+
{ label: "Reclaim (Recommended)", description: "Overwrites the stale lock and continues, because nothing has refreshed its heartbeat within its time-to-live. When that process is really dead, nothing of the old session is lost." },
|
|
73
89
|
{ label: "Abort — investigate manually", description: "Stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive." },
|
|
74
90
|
],
|
|
75
91
|
}],
|
|
@@ -78,15 +94,24 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
|
|
|
78
94
|
- **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
|
|
79
95
|
```
|
|
80
96
|
A stale session lock is in the way — started <ageHours>h ago on <host>, ttl=<ttlHours>h expired, last heartbeat <heartbeatAgeMinutes> minutes ago. Reclaim it?
|
|
81
|
-
|
|
97
|
+
(heartbeatAgeMinutes null → replace the ttl/heartbeat clause with "last heartbeat: never (lock has no last_heartbeat)"; negative → "last heartbeat stamped <N> minutes in the future"; ageHours null → "start time unreadable")
|
|
98
|
+
1. Reclaim (Recommended) — overwrites the stale lock and continues, because nothing has refreshed its heartbeat within its time-to-live.
|
|
82
99
|
2. Abort — stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive.
|
|
83
100
|
Reply with the number of your choice.
|
|
84
101
|
```
|
|
85
|
-
- On **Reclaim**: call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd() })
|
|
102
|
+
- On **Reclaim**: call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd(), onlyIfNotLive: true })` — the single-winner takeover (#1494): if another session reclaimed the lock in the meantime, it returns that session's `active` result instead of overwriting it; follow the decision flow for the returned result. After Phase 1.5 initializes STATE.md, append a deviation:
|
|
86
103
|
`Stale-lock reclaim: replaced lock from session_id=<existingLock.session_id>, age=<ageHours>h, pid=<existingLock.pid>`. Continue.
|
|
87
104
|
- On **Abort**: exit cleanly.
|
|
88
105
|
|
|
89
|
-
4. **`result.ok === false`** with `reason === '
|
|
106
|
+
4. **`result.ok === false`** with `reason === 'corrupt'`:
|
|
107
|
+
- `.orchestrator/session.lock` exists but is not a lock record (invalid JSON, or JSON without the lock shape). It can never be a live lock: every writer writes it atomically (tmp+link / tmp+rename), so no reader sees a half-written file. The SessionStart hook already reclaims this case mechanically and prints one stderr WARN; reaching it here means the hook did not run or its reclaim failed.
|
|
108
|
+
- No AUQ — the answer is derivable (AUQ-001.2). Log `⚠ session-lock: .orchestrator/session.lock was not a valid lock record — reclaiming.`, call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd(), onlyIfNotLive: true })` (a non-ok result means another session got there first — follow the decision flow for it), and after Phase 1.5 append a deviation: `Corrupt-lock reclaim: replaced an unparseable .orchestrator/session.lock`. Continue.
|
|
109
|
+
|
|
110
|
+
5. **`result.ok === false`** with `reason === 'unreadable'`:
|
|
111
|
+
- `.orchestrator/session.lock` exists but cannot be read (`result.error`, e.g. `EACCES`, or `EISDIR` for a directory at the path). Nothing was written, and it is never reclaimed: no lock writer produces such a path, and a directory may hold content nobody here created (PSA-003). The SessionStart hook already printed a stderr WARN and emitted `orchestrator.session.lock.read_anomaly` (`status: 'unreadable'`).
|
|
112
|
+
- No AUQ — the operator has to inspect the path; nothing the session could pick fixes it (AUQ-001.2). Log `⚠ session-lock: .orchestrator/session.lock exists but cannot be read (<error>). Continuing without lock (degraded mode) — inspect or remove it by hand.`, proceed without a lock, and after Phase 1.5 append a deviation: `Session lock unreadable (<error>) — ran without a lock`.
|
|
113
|
+
|
|
114
|
+
6. **`result.ok === false`** with `reason === 'fs-error'**:
|
|
90
115
|
- Filesystem error when writing the lock file. Log `⚠ session-lock: acquire failed — <error>. Continuing without lock (degraded mode).` and proceed without a lock. Do NOT block the session for a transient FS error.
|
|
91
116
|
|
|
92
117
|
> **New reasons from P1.2 #570:** When called with the optional `activeSessions` argument, `acquire()` can also return `active-incompatible-exclusive`, `active-compatible-parallel`, or `active-readonly-bypass`. Session-start invokes `acquire()` WITHOUT `activeSessions` (the preamble in Phase 0.5 already handled cross-worktree detection); these new reasons surface only in callers that bypass the preamble. Other entry-points (autopilot, session-plan, wave-executor, session-end) follow the same pattern.
|
|
@@ -39,7 +39,7 @@ This single call does three things:
|
|
|
39
39
|
2. **Re-derives THIS repo's status from its live lease**, so a stale lease left by a prior crashed session in this same repo renders as `force-closed` (heartbeat older than the v2 ttl, default 4h — `DEFAULT_TTL_HOURS` in `scripts/lib/session-lock.mjs`, evaluated via `isLockLive`) and is **never silently dropped** — its fields are read straight off the dead lock.
|
|
40
40
|
3. **Re-derives every OTHER busy repo's status host-wide** via `enumerateCandidates` — a dead lease in repo B renders `force-closed` on the board the next time ANY repo's session-start runs `sweepBoard`, closing the #676→#716 gap. `frei` (lock-less) repos are excluded from re-derivation to avoid board noise; their prior rows, and the prior rows of any repo `enumerateCandidates` did not surface, are preserved unchanged via the idempotent merge — never dropped.
|
|
41
41
|
|
|
42
|
-
`sweepBoard` internally calls `mirrorBoard`, which re-reads Session Config, resolves the host-local vault-dir, and **silently no-ops** (returning `{ action: 'skipped-vault-disabled' }`) when `vault-integration.enabled` is not `true`, the vault-dir is absent, the vault resolves outside `$HOME`, or the config is unreadable. The Bash gate above is the fast-path skip; this internal guard is the defense-in-depth backstop — both agree on the same condition.
|
|
42
|
+
`sweepBoard` internally calls `mirrorBoard`, which re-reads Session Config, resolves the host-local vault-dir, and **silently no-ops** (returning `{ action: 'skipped-vault-disabled' }`) when `vault-integration.enabled` is not `true`, the vault-dir is absent, the vault resolves outside `$HOME`, or the config is unreadable — and refuses with ONE stderr line (returning `{ action: 'skipped-vault-not-canonical' }`) when the vault's git origin does not end in a canonical Meta-Vault suffix (`checkCanonicalVault`, #1450; the same guard `vault-mirror.mjs` applies). The Bash gate above is the fast-path skip; this internal guard is the defense-in-depth backstop — both agree on the same condition.
|
|
43
43
|
|
|
44
44
|
### Safety invariants
|
|
45
45
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
3. **Pencil design status**: if `pencil` is configured, verify the `.pen` file exists at the configured path. Report: "Pencil design configured at [path] — design-code alignment reviews will run after Impl-Core and Impl-Polish waves." If file not found, warn: "Pencil path configured but file not found at [path]."
|
|
14
14
|
4. **Plugin freshness**: Determine the session-orchestrator plugin directory (navigate up from this skill's base directory to the plugin root). Run `git -C <plugin-dir> log -1 --format="%ci"` to get the last commit date. If older than `plugin-freshness-days` (default: 30) days, flag a warning in the Session Overview: `"⚠ Session Orchestrator plugin last updated [N] days ago — consider pulling the latest version."` Non-blocking — present in overview, don't halt.
|
|
15
15
|
|
|
16
|
-
**The
|
|
16
|
+
**The 20-probe banner family runs ONCE, mechanically — never invoke a probe below by hand (#1351).** `hooks/on-session-start.mjs` (the `SO_DISABLE_STARTUP_PROBES` block) imports `runSessionStartProbes()` from `scripts/lib/session-start-probes.mjs` on every SessionStart, and the `PROBES` registry in that module is the single declaration site for each probe's module path, entry function, arguments, precondition and network flag. The paragraphs below are the RENDERING reference — return contract and exact banner wording per probe — not call instructions: a hand-invocation runs the same measurement a second time at full cost (`project-hygiene` alone is a `git status --ignored` plus a batched `du` over every ignored path — seconds, not milliseconds, under load; see its own paragraph below for the measurement and the host conditions it was taken under). The runner returns `bannerLines`; the hook pushes them into the ONE `systemMessage` envelope (HR-106). Read those lines and present them in the Session Overview.
|
|
17
17
|
|
|
18
18
|
The bootstrap-lock-freshness probe (`scripts/lib/bootstrap-lock-freshness.mjs`, `checkBootstrapLockFreshness`) runs in the Phase 4 banner family as `id: 'bootstrap-lock-freshness'` in `scripts/lib/session-start-probes.mjs` `PROBES`, preconditioned on `.orchestrator/bootstrap.lock` existing and called with `currentPluginVersion` read from the plugin's `package.json` so version comparison is live. It checks lock age and plugin-version drift. When severity is `warn` or `alert`, render an additional banner alongside the plugin-freshness warning. The remediation is **reason-aware** (`result.details.reason`, #57) — a present-but-stale lock is never told to re-run `--retroactive` (idempotent no-op once `version`/`tier` already parse; see the Retroactive Flow's idempotency guard in `skills/bootstrap/SKILL.md`):
|
|
19
19
|
- **warn, `reason` = `stale-age` or `unparseable-timestamp`** (age 30–89d, or timestamp missing/unparseable but not yet ≥90d): `"⚠ bootstrap.lock: age=<N>d, plugin-version=<lock-ver> (current=<plugin-ver>) — run /bootstrap --refresh-lock to acknowledge and reset the freshness clock."`
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
|
|
145
145
|
Non-blocking. Cross-reference: `scripts/lib/instruction-budget-guard.mjs` (sibling directive-COUNT probe over `.claude/rules/*.md` — this probe measures raw-file PROPERTIES of CLAUDE.md/AGENTS.md itself, a distinct dimension) and issue #878 (FA2b).
|
|
146
146
|
|
|
147
|
-
The tests:src-ratio probe (`scripts/lib/tests-src-ratio.mjs`, `checkTestsSrcRatio`) runs in the Phase 4 banner family as `id: 'tests-src-ratio'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It returns `null` (silent no-op) when the ratio is inside the TV-003 corridor, when `repoRoot` is missing, or
|
|
147
|
+
The tests:src-ratio probe (`scripts/lib/tests-src-ratio.mjs`, `checkTestsSrcRatio`) runs in the Phase 4 banner family as `id: 'tests-src-ratio'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It returns `null` (silent no-op) when the ratio is inside the TV-003 corridor, when the ratio is undefined because the repo has no src code, when `repoRoot` is missing or does not exist, or when the measurement throws. It returns a warn in two cases: the ratio exceeds the ceiling, or the ratio is not measurable — 0 test files counted while test-like files are tracked (`reason: 'no-tests-found'`, `ratio: null`, message "tests:src ratio nicht messbar", #1451). The result shape is `{ severity: 'warn', message, ratio: number|null, ceiling, reason? }` — `reason` is set only on the not-measurable warn. Render `result.message` alongside the other banners.
|
|
148
148
|
|
|
149
149
|
**Why this is a banner and not a gate.** `.claude/rules/test-value.md` § TV-003 names the ceiling as the trigger for a CONSOLIDATION wave — the rule's operative instrument. Before this wiring the trigger fired into a void: the only references were two rule files asking a human to run the command, so the condition could be true for months with nothing saying so (it was true, at 1.70, on the commit that introduced the script). The rule's refusal of a bidirectional ratchet stands unchanged — this surfaces the trigger, it does not block on it. `--check` remains deliberately unwired from CI.
|
|
150
150
|
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
- **Findings present**: render the message verbatim. It already leads with the count and the mechanically-fixable subset, then names the top 3 and summarises the remainder — this shape was chosen because a flat list stops being read past roughly 25 findings.
|
|
157
157
|
- **Healthy repo**: silent (no banner).
|
|
158
158
|
|
|
159
|
-
Use `result.mechanical` when proposing session scope: findings with `fixable: true` (aged artifacts, ignored ballast, a missing CI audit step) are safe batch work, while the rest (release cadence, absent CI, undocumented configuration) need an operator decision and belong in the Q&A, not in an auto-fix batch.
|
|
159
|
+
Use `result.mechanical` when proposing session scope: findings with `fixable: true` (aged artifacts, ignored ballast, a missing CI audit step — an audit that cannot be verified because `.gitlab-ci.yml` pulls jobs via `include:` is reported `fixable: false` instead) are safe batch work, while the rest (release cadence, absent CI, undocumented configuration) need an operator decision and belong in the Q&A, not in an auto-fix batch.
|
|
160
160
|
|
|
161
161
|
The checks are: release-tag/CHANGELOG distance from HEAD, ignored working-tree ballast plus files that are neither tracked nor ignored, aged `.orchestrator/` artifacts, CI pipeline presence and dependency-audit coverage, and `.env.example` presence. Two high-yield checks are intentionally NOT here: **docs-drift** is already covered by `claude-md-drift-check` (it only runs at session-END, so the gap is scheduling, not implementation), and **env completeness** is omitted because diffing `process.env` reads against `.env.example` produced a 100% false-positive rate against code that reads configuration through a central schema module.
|
|
162
162
|
|
|
@@ -176,5 +176,12 @@
|
|
|
176
176
|
|
|
177
177
|
Non-blocking. Cross-reference: `scripts/lib/vcs-repo-spec.mjs` (`isQueryFailure` — the same absence-vs-query-failure split this probe's `degraded` state implements).
|
|
178
178
|
|
|
179
|
+
The events-retention probe (`scripts/lib/events-retention-banner.mjs`, `checkEventsRetention`) runs in the Phase 4 banner family as `id: 'events-retention'` in `scripts/lib/session-start-probes.mjs` `PROBES` (#1401). `events-rotation.max-backups` bounds how MANY archives `_archive/` keeps; the readers of the rotated ledger need a TIME window, declared as `REQUIRED_EVENTS_WINDOW_DAYS` beside every reader call — `readEventsWithRotations(`, `scanEventsBackwards(` and `listEventSourcesNewestFirst(`, seven readers censused from source by `tests/lib/events-retention-banner.test.mjs` (`telemetry/sync.mjs` 1 day — a chosen ceiling, the assumed maximum age of the latest `orchestrator.session.started` when a flush reads it; `eval/engine.mjs`, `tmux-layout/telemetry-stats.mjs`, `maintenance-due-banner.mjs`, `instruction-budget-guard.mjs`, `backfill-abandoned-sessions.mjs` and `session-close-backfill.mjs` `null` — no fixed window: their questions span the whole history, and the backward scan reports a pruned history as `gaps`, so maintenance-due answers `undeterminable` instead of "never", #1489). The probe compares the span from the oldest retained archive's first timestamp to now against the largest finite declaration, and only once the archive ring is FULL (`archives >= max-backups`) — below that the rotator prunes nothing under the current config, so the ledger only grows and the probe stays silent. On a full ring the archives alone are judged: the rotator never prunes a legacy `events.jsonl.N`, so its older stamp would span the hole a pruned archive left (#1489 review). "Not full" is not "never pruned": after `max-backups` is raised, archives pruned under the old ring stay gone — the oldest retained archive's rotation record names a predecessor (`archived_as`) that no longer exists — and the silent coverage figure then starts at the oldest archive as on a full ring. The hole goes unseen only when the oldest archive's head holds no rotation record — the first archive a ledger ever wrote, before which no archive can have been pruned, or a later one whose tombstone was never written (`recordWritten: false`); measured 2026-10-02 over the 5 fleet `_archive/` dirs: 4 of 4 first archives without one, 2 of 2 later archives with it on line 0 (#1498). No Session Config key of its own. **Three states:** no live ledger and no archive in `_archive/` (absent, empty, or holding only names outside `ARCHIVE_NAME_RE`) → registry precondition, recorded `skipped: no-events-ledger` (never `ran-clean`); `_archive/` unlistable, a reader's declaration unreadable, the oldest retained stamp dated after now (a future-dated archive or a clock set back — the span would be negative), or no stamp at all (no archive and no parseable `timestamp` in the head of `events.jsonl` or a legacy `events.jsonl.N`, #1498) → `{ severity: 'warn', degraded: true }`, never rendered as covered; measured → `{ severity: 'ok', kind: 'ring-not-full' | 'no-finite-window' | 'covered' }` or a warn, and every measured answer returns `coverageDays`, `oldestEventAt`, `requiredDays` / `requiredBy`, `archives` and `maxBackups` to its caller even when silent. Of these, the registry entry's `telemetry` persists `coverage_days`, `archives`, `max_backups` and `required_days` as `measure` on this probe's element of `orchestrator.probes.completed` — on the silent answers too, which the runner records as `ran-clean` — so whether the warning could have fired is answerable from the ledger (HR-105); `oldestEventAt` and `requiredBy` are not persisted. A degraded answer persists no `measure`. Render `result.message`:
|
|
180
|
+
- **warn** (full ring covers less than the required window): `"⚠ events-retention: the rotated events ledger covers <X>d (<N> archives at events-rotation.max-backups: <M>), less than the <D>d window <reader> declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than <X>d are not retained; raise events-rotation.max-backups or max-size-mb."`
|
|
181
|
+
- **degraded, `_archive/` unlistable**: `"⚠ events-retention: could not list _archive/ (<code>) — retention span not measured, which is not the same as covered."`
|
|
182
|
+
- **degraded, a reader's declaration unreadable** (on every measured start, full ring or not — the reader modules are imported BEFORE the ring check, because `requiredDays` is part of the silent measurement; `<error>` is the import or validation error, cut to 120 characters): `"⚠ events-retention: required window not readable (<error>) — retention span not judged, which is not the same as covered."`
|
|
183
|
+
- **degraded, oldest retained stamp after now**: `"⚠ events-retention: the oldest retained event is dated <ISO timestamp>, after now — retention span not measured, which is not the same as covered."`
|
|
184
|
+
- **degraded, no stamp in any source** (no archive, and no parseable `timestamp` in the first 64 KiB of `events.jsonl` or a legacy `events.jsonl.N`, #1498): `"⚠ events-retention: no parseable timestamp in the first 64 KiB of events.jsonl (or of a legacy events.jsonl.N) and no _archive/ archive — retention span not measured, which is not the same as covered."`
|
|
185
|
+
|
|
179
186
|
All banners are non-blocking — display in the Session Overview, do not halt the session. If `bootstrap-lock-freshness.mjs` is absent (pre-#186 plugin install) or `peer-cards/staleness-banner.mjs` is absent (pre-#503 plugin install) or `loop-readiness-banner.mjs` is absent (pre-#633 plugin install) or `instruction-budget-guard.mjs` is absent (pre-#687 plugin install) or `maintenance-due-banner.mjs` / `reconcile-nudge-banner.mjs` are absent (pre-#723 plugin install) or `sessions-staleness-banner.mjs` is absent (pre-#724 plugin install) or `sessions-integrity-banner.mjs` is absent (pre-#958 plugin install) or `owner-config-banner.mjs` is absent (pre-#820 plugin install) or `moc-staleness-banner.mjs` / `context-coverage-banner.mjs` are absent (pre-#831 plugin install) or `claude-md-budget-lint.mjs` is absent (pre-#878 plugin install) or `mirror-issues-banner.mjs` is absent (pre-#1022 plugin install), skip silently.
|
|
180
187
|
|