session-orchestrator 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions-integrity-banner.mjs — GitLab #958 finding 3 (visibility half).
|
|
3
|
+
*
|
|
4
|
+
* Deterministic session-start nudge for the "silently invalid ledger record"
|
|
5
|
+
* gap: `.orchestrator/metrics/sessions.jsonl` accumulates records that fail
|
|
6
|
+
* the repo's OWN `validateSession()`, and nobody is told.
|
|
7
|
+
*
|
|
8
|
+
* Why the loss is silent (W1/D4, 2026-07-31, HEAD 1f7b449): the ledger record
|
|
9
|
+
* is composed by the coordinator from a Markdown template
|
|
10
|
+
* (`skills/session-end/metrics-collection.md`) and appended directly,
|
|
11
|
+
* bypassing `scripts/emit-session.mjs` — which validates and would have
|
|
12
|
+
* exited 1. Downstream, `scripts/vault-mirror.mjs` logs the casualty as
|
|
13
|
+
* `{"action":"skipped-invalid"}` on stdout and still exits 0, so the affected
|
|
14
|
+
* sessions simply have no vault note. A prose prohibition already exists in
|
|
15
|
+
* `skills/session-end/session-metrics-write.md` and did not stop it; this
|
|
16
|
+
* banner is the instrument that makes the residue visible at session-start.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the contract used by the sibling Phase 4 banners
|
|
19
|
+
* (`scripts/lib/sessions-staleness-banner.mjs`,
|
|
20
|
+
* `scripts/lib/reconcile-nudge-banner.mjs`,
|
|
21
|
+
* `scripts/lib/vault-staleness-banner.mjs`): a single `checkXxx({repoRoot})`
|
|
22
|
+
* entry point that is COMPLETELY try/catch-wrapped (never throws) and returns
|
|
23
|
+
* either `null` (silent no-op) or `{severity:'warn'|'alert', message, ...extra}`.
|
|
24
|
+
*
|
|
25
|
+
* TWO VALIDATORS OVER ONE STORE — why this banner reports both populations.
|
|
26
|
+
* The repo has two independent notions of "a valid session record", and
|
|
27
|
+
* neither one contains the other:
|
|
28
|
+
*
|
|
29
|
+
* - `validateSession()` (`scripts/lib/session-schema/validator.mjs`) — the
|
|
30
|
+
* canonical write-path schema, enforced by `scripts/emit-session.mjs`.
|
|
31
|
+
* It treats `effectiveness` as OPTIONAL/nullable — since #964 declared
|
|
32
|
+
* explicitly in `OPTIONAL_FIELDS` (`session-schema/constants.mjs`) rather
|
|
33
|
+
* than inferable only from an `if` — so a record vault-mirror refuses to
|
|
34
|
+
* render can pass it cleanly.
|
|
35
|
+
* - vault-mirror's render path — its own, differently-shaped requirement
|
|
36
|
+
* set (`RENDERABLE_SESSION_FIELDS_V1` / `_V2` / `_V3`, exported from
|
|
37
|
+
* `scripts/lib/vault-mirror/render-sessions.mjs` and consumed by the
|
|
38
|
+
* matching `generateSessionNote*` generator, routed by
|
|
39
|
+
* `detectSessionSchema`), which REQUIRES `effectiveness` to be present.
|
|
40
|
+
* A record can therefore be schema-valid and still get no vault note.
|
|
41
|
+
* Since #964 those lists are module-scope and pinned to the write-path
|
|
42
|
+
* schema by a mechanical superset test (v1 ⊇ `REQUIRED_FIELDS`, with
|
|
43
|
+
* measured carve-outs for the write-unreachable v2/v3) — so "renderable"
|
|
44
|
+
* is now a stated strengthening of "schema-valid" rather than an
|
|
45
|
+
* undeclared second opinion. That relationship is exactly why BOTH
|
|
46
|
+
* populations still have to be reported: a superset can still exclude.
|
|
47
|
+
*
|
|
48
|
+
* Reporting one population would hide the other, so the banner names both and
|
|
49
|
+
* attributes each to its own consequence.
|
|
50
|
+
*
|
|
51
|
+
* Live ledger at HEAD 730ee9d (measured 2026-08-03; 205 parseable records,
|
|
52
|
+
* 146 of them `status: 'abandoned'`, all 205 routing to the v1 generator):
|
|
53
|
+
* `validateSession` fails 0, vault-mirror render fails 10 — and all 10 of
|
|
54
|
+
* those are abandoned, i.e. discarded by the #909 filter BEFORE the render
|
|
55
|
+
* path (see `mirrorSkipReason` below), so the banner is correctly silent
|
|
56
|
+
* here. The counts move as the ledger grows; treat them as a dated
|
|
57
|
+
* measurement, never as an invariant — re-run the probe before quoting them.
|
|
58
|
+
*
|
|
59
|
+
* The vault-mirror population is measured by CALLING THE REAL RENDER PATH in
|
|
60
|
+
* a try/catch — never by re-deriving its required-field list here. A third
|
|
61
|
+
* copy of that list would reproduce the very defect this banner reports
|
|
62
|
+
* (`.claude/rules/testing.md` § Unfaithful Double: ask the production code
|
|
63
|
+
* what it would do, do not model it). The same rule is why the #909 abandoned
|
|
64
|
+
* filter is imported from `session-schema/filters.mjs` and not re-typed.
|
|
65
|
+
*
|
|
66
|
+
* Severity is grounded in consequence, not in an arbitrary count threshold:
|
|
67
|
+
* - `warn` — records are schema-invalid, but every one of them still
|
|
68
|
+
* mirrors: the ledger is corrupt, nothing is lost yet.
|
|
69
|
+
* - `alert` — at least one record is DROPPED by vault-mirror: those
|
|
70
|
+
* sessions have no vault note right now.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately un-gated (no Session Config key, like `project-hygiene`) — a
|
|
73
|
+
* check nobody enables finds nothing.
|
|
74
|
+
*
|
|
75
|
+
* Plain-JS — no Zod dependency. Never throws. Never mutates input. No
|
|
76
|
+
* `console.*` calls (repo ESLint `no-console` rule).
|
|
77
|
+
*
|
|
78
|
+
* Cross-references:
|
|
79
|
+
* - `scripts/lib/session-schema/validator.mjs` — `validateSession`.
|
|
80
|
+
* - `scripts/lib/session-schema/filters.mjs` — `isRealSession` (#909).
|
|
81
|
+
* - `scripts/lib/vault-mirror/process.mjs` — the routing this probe mirrors.
|
|
82
|
+
* - `scripts/lib/vault-mirror/render-sessions.mjs` — the real render path,
|
|
83
|
+
* and the `RENDERABLE_SESSION_FIELDS_V{1,2,3}` lists it gates on (#964).
|
|
84
|
+
* - `scripts/lib/session-schema/constants.mjs` — `REQUIRED_FIELDS` /
|
|
85
|
+
* `OPTIONAL_FIELDS`, the write-path half of the superset relationship.
|
|
86
|
+
* - `scripts/emit-session.mjs` — the validating writer this banner points at.
|
|
87
|
+
* - `hooks/pre-bash-sessions-ledger-guard.mjs` — the write-guard half of #958.
|
|
88
|
+
* - `skills/session-start/SKILL.md` Phase 4 — banner render site.
|
|
89
|
+
* - `.claude/rules/verification-before-completion.md` — evidence-before-claims.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
93
|
+
import path from 'node:path';
|
|
94
|
+
|
|
95
|
+
import { isRealSession } from './session-schema/filters.mjs';
|
|
96
|
+
import { validateSession } from './session-schema/validator.mjs';
|
|
97
|
+
import {
|
|
98
|
+
detectSessionSchema,
|
|
99
|
+
normalizeSessionEntry,
|
|
100
|
+
generateSessionNote,
|
|
101
|
+
generateSessionNoteV2,
|
|
102
|
+
generateSessionNoteV3,
|
|
103
|
+
} from './vault-mirror/render-sessions.mjs';
|
|
104
|
+
|
|
105
|
+
/** Repo-relative path to the session ledger (one record per closed session). */
|
|
106
|
+
const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
|
|
107
|
+
|
|
108
|
+
/** Max session_ids listed inline before the message collapses to "+N more". */
|
|
109
|
+
export const MAX_LISTED_IDS = 5;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the ledger's non-empty lines. Returns `null` when the file is absent
|
|
113
|
+
* or unreadable, `[]` when it exists but has no non-empty lines. Never throws.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} filePath
|
|
116
|
+
* @returns {string[]|null}
|
|
117
|
+
*/
|
|
118
|
+
function readJsonlLines(filePath) {
|
|
119
|
+
if (!existsSync(filePath)) return null;
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(filePath, 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return raw.split('\n').filter((line) => line.length > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Ask the REAL vault-mirror render path whether it would skip this entry AS
|
|
131
|
+
* `skipped-invalid`. Reproduces `scripts/lib/vault-mirror/process.mjs`
|
|
132
|
+
* `processSession()` routing in its production ORDER: normalize aliases (#635)
|
|
133
|
+
* → detect schema → select that schema's generator → **#909 abandoned filter**
|
|
134
|
+
* → invoke the generator. A throw from the generator is precisely what
|
|
135
|
+
* `scripts/vault-mirror.mjs` catches and reports as
|
|
136
|
+
* `{"action":"skipped-invalid"}`.
|
|
137
|
+
*
|
|
138
|
+
* The abandoned filter is load-bearing, not decoration. `process.mjs:485`
|
|
139
|
+
* returns `skipped-abandoned` for `!isRealSession(entry)` BEFORE the generator
|
|
140
|
+
* is ever called, so an abandoned record can never reach the render path and
|
|
141
|
+
* can never become `skipped-invalid`. Omitting it made this probe measure a
|
|
142
|
+
* population production never reaches: on this repo's ledger (measured
|
|
143
|
+
* 2026-08-03, HEAD 730ee9d, 205 records) **all 10** reported "dropped" records
|
|
144
|
+
* were `status: 'abandoned'` — a 100 %-false 🚨 on every session start, with a
|
|
145
|
+
* prescribed remedy (re-emit) that produces no vault note for such a record.
|
|
146
|
+
* The predicate is IMPORTED from `session-schema/filters.mjs` rather than
|
|
147
|
+
* re-derived: that rule already has exactly two homes (the filter module and
|
|
148
|
+
* `process.mjs`'s call site), and a hand-copied `status !== 'abandoned'` here
|
|
149
|
+
* would make a third — the divergence class this banner exists to report.
|
|
150
|
+
*
|
|
151
|
+
* The generator is invoked with one argument where production passes
|
|
152
|
+
* `generator(entry, { repoNs })` (`process.mjs:516` — the SESSION render whose
|
|
153
|
+
* throw becomes `skipped-invalid`; the 3-argument `generator(entry, slug,
|
|
154
|
+
* generatorOpts)` calls elsewhere in that file belong to the separate
|
|
155
|
+
* LEARNINGS path and are not this probe's population). All three generators
|
|
156
|
+
* declare `options = {}` and use `repoNs` only for a frontmatter field, never
|
|
157
|
+
* to decide whether to throw — so the omission cannot change the render/skip
|
|
158
|
+
* verdict this probe reads. Resolving a real `repoNs` here would cost a git
|
|
159
|
+
* subprocess per record for a value the verdict ignores.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} record
|
|
162
|
+
* @returns {string|null} the skip reason, or `null` when production would
|
|
163
|
+
* either render it or discard it for a reason OTHER than invalidity.
|
|
164
|
+
*/
|
|
165
|
+
function mirrorSkipReason(record) {
|
|
166
|
+
try {
|
|
167
|
+
const entry = normalizeSessionEntry(record);
|
|
168
|
+
const schema = detectSessionSchema(entry);
|
|
169
|
+
const generator =
|
|
170
|
+
schema === 'v3' ? generateSessionNoteV3 : schema === 'v2' ? generateSessionNoteV2 : generateSessionNote;
|
|
171
|
+
// #909 filter — production returns `skipped-abandoned` here and never
|
|
172
|
+
// reaches the generator. Not a render failure; not this banner's signal.
|
|
173
|
+
if (!isRealSession(entry)) return null;
|
|
174
|
+
generator(entry);
|
|
175
|
+
return null;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return err && typeof err.message === 'string' ? err.message : 'unknown render failure';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Render `<id>, <id>, … (+N more)` for a bounded id list.
|
|
183
|
+
*
|
|
184
|
+
* @param {{sessionId: string}[]} rows
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function formatIds(rows) {
|
|
188
|
+
const ids = rows.map((r) => r.sessionId);
|
|
189
|
+
const shown = ids.slice(0, MAX_LISTED_IDS).join(', ');
|
|
190
|
+
const rest = ids.length - MAX_LISTED_IDS;
|
|
191
|
+
return rest > 0 ? `${shown} (+${rest} more)` : shown;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check sessions-ledger schema integrity and produce a session-start banner.
|
|
196
|
+
*
|
|
197
|
+
* Silent (`null`) when: `sessions.jsonl` is missing, empty, unreadable, holds
|
|
198
|
+
* no parseable JSON line at all, or every parseable record satisfies BOTH
|
|
199
|
+
* `validateSession()` and the vault-mirror render path. An `abandoned` record
|
|
200
|
+
* satisfies the render half vacuously — production discards it as
|
|
201
|
+
* `skipped-abandoned` before rendering, so it can never be `skipped-invalid`
|
|
202
|
+
* and is never counted in `mirrorSkipped`. Unparseable lines are
|
|
203
|
+
* SKIPPED, not reported — this probe judges schema integrity, not file
|
|
204
|
+
* corruption (matching the sibling banners' malformed-line handling). Never
|
|
205
|
+
* throws.
|
|
206
|
+
*
|
|
207
|
+
* @param {{repoRoot: string}} opts
|
|
208
|
+
* - `repoRoot`: REQUIRED absolute path to the repo root.
|
|
209
|
+
* @returns {null | {
|
|
210
|
+
* severity: 'warn'|'alert',
|
|
211
|
+
* message: string,
|
|
212
|
+
* total: number,
|
|
213
|
+
* schemaInvalid: {line: number, sessionId: string, error: string}[],
|
|
214
|
+
* mirrorSkipped: {line: number, sessionId: string, error: string}[],
|
|
215
|
+
* }}
|
|
216
|
+
*/
|
|
217
|
+
export function checkSessionsIntegrity({ repoRoot } = {}) {
|
|
218
|
+
try {
|
|
219
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
220
|
+
|
|
221
|
+
const lines = readJsonlLines(path.join(repoRoot, SESSIONS_PATH));
|
|
222
|
+
if (lines === null || lines.length === 0) return null;
|
|
223
|
+
|
|
224
|
+
/** @type {{line: number, sessionId: string, error: string}[]} */
|
|
225
|
+
const schemaInvalid = [];
|
|
226
|
+
/** @type {{line: number, sessionId: string, error: string}[]} */
|
|
227
|
+
const mirrorSkipped = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
let record;
|
|
232
|
+
try {
|
|
233
|
+
record = JSON.parse(lines[i]);
|
|
234
|
+
} catch {
|
|
235
|
+
continue; // unparseable line — not this probe's concern
|
|
236
|
+
}
|
|
237
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
238
|
+
total += 1;
|
|
239
|
+
|
|
240
|
+
const sessionId =
|
|
241
|
+
typeof record.session_id === 'string' && record.session_id.length > 0
|
|
242
|
+
? record.session_id
|
|
243
|
+
: '<no session_id>';
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
validateSession(record);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
schemaInvalid.push({
|
|
249
|
+
line: i + 1,
|
|
250
|
+
sessionId,
|
|
251
|
+
error: err && typeof err.message === 'string' ? err.message : 'unknown validation failure',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const skipReason = mirrorSkipReason(record);
|
|
256
|
+
if (skipReason !== null) {
|
|
257
|
+
mirrorSkipped.push({ line: i + 1, sessionId, error: skipReason });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (total === 0) return null;
|
|
262
|
+
if (schemaInvalid.length === 0 && mirrorSkipped.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
// alert only when records are ACTUALLY being dropped by vault-mirror —
|
|
265
|
+
// those sessions have no vault note right now. A schema-invalid record
|
|
266
|
+
// that still mirrors is corruption without loss: warn.
|
|
267
|
+
const severity = mirrorSkipped.length > 0 ? 'alert' : 'warn';
|
|
268
|
+
|
|
269
|
+
const parts = [];
|
|
270
|
+
if (schemaInvalid.length > 0) {
|
|
271
|
+
parts.push(
|
|
272
|
+
`${schemaInvalid.length} of ${total} records fail validateSession (${formatIds(schemaInvalid)})`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (mirrorSkipped.length > 0) {
|
|
276
|
+
parts.push(
|
|
277
|
+
`${mirrorSkipped.length} are dropped by vault-mirror as skipped-invalid — those sessions have NO vault note (${formatIds(mirrorSkipped)})`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const base =
|
|
282
|
+
`sessions-integrity: ${parts.join('; ')} — records were appended without passing ` +
|
|
283
|
+
`scripts/emit-session.mjs (which validates and would have refused). ` +
|
|
284
|
+
`Inspect: node -e with validateSession from scripts/lib/session-schema/validator.mjs; ` +
|
|
285
|
+
`re-emit affected records via scripts/emit-session.mjs`;
|
|
286
|
+
|
|
287
|
+
const message = severity === 'alert' ? `🚨 ${base}.` : `⚠ ${base}.`;
|
|
288
|
+
|
|
289
|
+
return { severity, message, total, schemaInvalid, mirrorSkipped };
|
|
290
|
+
} catch {
|
|
291
|
+
// Defensive catch-all — banner must never throw.
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
* by the time this probe fires. Gating on "no live lock" would make the
|
|
21
21
|
* banner structurally silent forever. Instead:
|
|
22
22
|
*
|
|
23
|
-
* - `lastLedgerAt` = `completed_at` of the last PARSEABLE
|
|
24
|
-
* `sessions.jsonl` record
|
|
25
|
-
* backward, skipping malformed
|
|
23
|
+
* - `lastLedgerAt` = `completed_at` of the last PARSEABLE, GENUINE
|
|
24
|
+
* (non-backfill-stub) `sessions.jsonl` record
|
|
25
|
+
* (scanned from EOF backward, skipping malformed
|
|
26
|
+
* lines) — see "Backfill-stub self-erasure fix"
|
|
27
|
+
* below for the stub-skip logic and its fallback.
|
|
26
28
|
* - `cutoff` = the CURRENT session's `session.lock`
|
|
27
29
|
* `started_at` (via `readLock()`); when no lock is
|
|
28
30
|
* readable, `cutoff = now` (all events count).
|
|
@@ -36,6 +38,45 @@
|
|
|
36
38
|
* meaningful when > 0 (foreign activity happened
|
|
37
39
|
* AFTER the last ledger entry).
|
|
38
40
|
*
|
|
41
|
+
* Backfill-stub self-erasure fix — the anchor axis: a backfill-produced
|
|
42
|
+
* `sessions.jsonl` record (`_backfill_source` / `status: 'abandoned'`, see
|
|
43
|
+
* `session-close-backfill.mjs` `synthesizeRecord()`) sets `completed_at =
|
|
44
|
+
* max(started_at, lastTerminalMs ?? nowMs)`. When the abandoned session
|
|
45
|
+
* never emitted a STOPPED/ENDED event — the COMMON case, since that is
|
|
46
|
+
* *why* it is "abandoned" — `completed_at` silently becomes the BACKFILL
|
|
47
|
+
* RUN's own wall-clock instant, not a measurement of when the session
|
|
48
|
+
* actually ended. Anchoring `lastLedgerEntry()` on that value means a
|
|
49
|
+
* backfill run can retroactively erase a multi-day staleness gap just by
|
|
50
|
+
* writing a stub today (observed: a 92.5h gap to the last GENUINE record
|
|
51
|
+
* collapsed to 0.6h the moment a backfill stub landed).
|
|
52
|
+
*
|
|
53
|
+
* Two axes were available to fix this: (a) skip stub records when scanning
|
|
54
|
+
* for the ledger anchor, keeping `completed_at` as the anchor field; or (b)
|
|
55
|
+
* blanket-switch the anchor field to `started_at` for every record. (b) was
|
|
56
|
+
* rejected — for a GENUINE multi-hour session, `started_at` sits hours
|
|
57
|
+
* before `completed_at`, so switching the anchor field universally would
|
|
58
|
+
* inflate `deltaHours` for perfectly healthy, promptly-closed sessions
|
|
59
|
+
* (a session's own mid-session events would newly count as "after" the
|
|
60
|
+
* anchor), reintroducing false positives on the opposite side. (a) is
|
|
61
|
+
* chosen: `lastLedgerEntry()` skips any record `isBackfillStub()` flags and
|
|
62
|
+
* keeps searching backward for a GENUINE `completed_at`. Stub recognition
|
|
63
|
+
* uses EITHER marker (OR, not AND) deliberately — both are set by the same
|
|
64
|
+
* producer today, but requiring both would silently stop matching the day a
|
|
65
|
+
* future backfill variant drops one of them while keeping the other; OR
|
|
66
|
+
* degrades gracefully (still catches it), AND does not.
|
|
67
|
+
*
|
|
68
|
+
* All-stub fallback (deliberately NOT null): when NO genuine record exists
|
|
69
|
+
* anywhere in the file — every record is a backfill stub — this is a
|
|
70
|
+
* STRONGER signal of the close-through gap than an ordinary stale ledger,
|
|
71
|
+
* not a weaker one: no session has EVER genuinely closed. The module's
|
|
72
|
+
* usual fail-quiet convention (null on missing/empty/ambiguous input) does
|
|
73
|
+
* not extend to "we have data but all of it is synthetic" — that state IS
|
|
74
|
+
* the failure this banner exists to catch, so `lastLedgerEntry()` instead
|
|
75
|
+
* anchors on the newest stub's `started_at` (grounded in the real
|
|
76
|
+
* `orchestrator.session.started` event in the common case — see
|
|
77
|
+
* `synthesizeRecord()` — unlike that same stub's fabricated `completed_at`)
|
|
78
|
+
* and flags the result `stubFallback: true` for callers that want to say so.
|
|
79
|
+
*
|
|
39
80
|
* Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
|
|
40
81
|
* `session-lock.mjs` rather than duplicated), alert above 24h.
|
|
41
82
|
*
|
|
@@ -60,6 +101,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
60
101
|
import path from 'node:path';
|
|
61
102
|
|
|
62
103
|
import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
104
|
+
import { isRealSession } from './session-schema/filters.mjs';
|
|
63
105
|
|
|
64
106
|
/** Repo-relative path to the session ledger (one record per closed session). */
|
|
65
107
|
const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
|
|
@@ -93,15 +135,51 @@ function readJsonlLines(filePath) {
|
|
|
93
135
|
}
|
|
94
136
|
|
|
95
137
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
138
|
+
* True when a `sessions.jsonl` record's `completed_at` was SYNTHESIZED by
|
|
139
|
+
* the backfill engine (`scripts/lib/session-close-backfill.mjs`
|
|
140
|
+
* `synthesizeRecord()`) rather than measured at real session-close time —
|
|
141
|
+
* see the module-header "Backfill-stub self-erasure fix" section above for
|
|
142
|
+
* the full reasoning behind the OR (not AND) combination of the two markers.
|
|
143
|
+
*
|
|
144
|
+
* Reuses `isRealSession()` from `./session-schema/filters.mjs` — its own doc
|
|
145
|
+
* names `status: 'abandoned'` "the canonical marker" for exactly this phantom
|
|
146
|
+
* class, so this is the SAME predicate every other real/phantom-aware
|
|
147
|
+
* consumer in this repo already relies on, not a hand-rolled duplicate of it.
|
|
148
|
+
* `_backfill_source` is layered on top as the second, independent signal.
|
|
149
|
+
* Caller guarantees `record` is already a non-null object (see
|
|
150
|
+
* `lastLedgerEntry()`'s guard above the call site).
|
|
151
|
+
*
|
|
152
|
+
* @param {object} record
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
function isBackfillStub(record) {
|
|
156
|
+
if (!isRealSession(record)) return true;
|
|
157
|
+
return typeof record._backfill_source === 'string' && record._backfill_source.length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scan `sessions.jsonl` lines from EOF backward and return the anchor
|
|
162
|
+
* instant to measure ledger staleness against. Malformed or non-conforming
|
|
163
|
+
* lines (bad JSON, non-object) are skipped, not treated as fatal.
|
|
164
|
+
*
|
|
165
|
+
* Two passes, in priority order:
|
|
166
|
+
* 1. GENUINE — the last (by position) record that is NOT `isBackfillStub()`
|
|
167
|
+
* and carries a valid `completed_at`. This is the trustworthy case:
|
|
168
|
+
* `completed_at` was written by the real session-end path.
|
|
169
|
+
* 2. STUB-FALLBACK — only reached when the loop above finds no genuine
|
|
170
|
+
* record at all (every record is a stub, or the file has none). Anchors
|
|
171
|
+
* on the newest-by-position stub's `started_at` instead of its
|
|
172
|
+
* `completed_at` — see the module-header design note for why. Flags
|
|
173
|
+
* `stubFallback: true` on the returned object; omitted (`undefined`) on
|
|
174
|
+
* the genuine path so existing callers checking `ledger.ms`/`ledger.iso`
|
|
175
|
+
* see no behavioural change.
|
|
100
176
|
*
|
|
101
177
|
* @param {string[]} lines
|
|
102
|
-
* @returns {{iso: string, ms: number}|null}
|
|
178
|
+
* @returns {{iso: string, ms: number, stubFallback?: true}|null}
|
|
103
179
|
*/
|
|
104
180
|
function lastLedgerEntry(lines) {
|
|
181
|
+
let newestStub = null; // newest-by-position stub with a parseable started_at
|
|
182
|
+
|
|
105
183
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
106
184
|
let record;
|
|
107
185
|
try {
|
|
@@ -109,12 +187,28 @@ function lastLedgerEntry(lines) {
|
|
|
109
187
|
} catch {
|
|
110
188
|
continue;
|
|
111
189
|
}
|
|
112
|
-
if (!record || typeof record !== 'object'
|
|
190
|
+
if (!record || typeof record !== 'object') continue;
|
|
191
|
+
|
|
192
|
+
if (isBackfillStub(record)) {
|
|
193
|
+
// Never anchor on a stub's completed_at (it may be the backfill run's
|
|
194
|
+
// own wall-clock) — remember it only as a fallback candidate, and only
|
|
195
|
+
// the first (nearest-EOF, i.e. newest-by-position) one seen.
|
|
196
|
+
if (newestStub === null && typeof record.started_at === 'string') {
|
|
197
|
+
const startedMs = Date.parse(record.started_at);
|
|
198
|
+
if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof record.completed_at !== 'string') continue;
|
|
113
204
|
const ms = Date.parse(record.completed_at);
|
|
114
205
|
if (!Number.isFinite(ms)) continue;
|
|
115
206
|
return { iso: record.completed_at, ms };
|
|
116
207
|
}
|
|
117
|
-
|
|
208
|
+
|
|
209
|
+
// No genuine record anywhere — see module-header "All-stub fallback" note:
|
|
210
|
+
// this is a stronger alarm signal than null, not a null-worthy absence.
|
|
211
|
+
return newestStub ? { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true } : null;
|
|
118
212
|
}
|
|
119
213
|
|
|
120
214
|
/**
|
|
@@ -182,10 +276,16 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
182
276
|
* Check sessions-ledger staleness and produce a session-start banner.
|
|
183
277
|
*
|
|
184
278
|
* Silent (`null`) when: `sessions.jsonl` is missing/empty/entirely
|
|
185
|
-
* unparseable
|
|
279
|
+
* unparseable-or-anchor-less (see `lastLedgerEntry()` — this now also
|
|
280
|
+
* covers "every record is a backfill stub with no parseable `started_at`
|
|
281
|
+
* anywhere"), `events.jsonl` is missing/empty, no foreign (pre-cutoff)
|
|
186
282
|
* event exists, the foreign event is not after the last ledger entry, or the
|
|
187
283
|
* resulting gap is under the warn threshold. Never throws.
|
|
188
284
|
*
|
|
285
|
+
* When the anchor comes from the all-stub fallback (`ledger.stubFallback`),
|
|
286
|
+
* `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
|
|
287
|
+
* message says so explicitly rather than implying a real close was measured.
|
|
288
|
+
*
|
|
189
289
|
* @param {{repoRoot: string, now?: number}} opts
|
|
190
290
|
* - `repoRoot`: REQUIRED absolute path to the repo root.
|
|
191
291
|
* - `now`: optional injectable clock (epoch ms); defaults to `Date.now()`.
|
|
@@ -196,6 +296,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
196
296
|
* lastLedgerAt: string,
|
|
197
297
|
* lastForeignEventAt: string,
|
|
198
298
|
* deltaHours: number,
|
|
299
|
+
* stubFallback?: true,
|
|
199
300
|
* }}
|
|
200
301
|
*/
|
|
201
302
|
export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
@@ -226,8 +327,15 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
226
327
|
|
|
227
328
|
const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
|
|
228
329
|
|
|
330
|
+
// stubFallback (see lastLedgerEntry()): every sessions.jsonl record is a
|
|
331
|
+
// backfill stub — ledger.iso is a STUB's started_at, not a measured
|
|
332
|
+
// completed_at. Say so explicitly rather than implying a real close.
|
|
333
|
+
const ledgerDescription = ledger.stubFallback
|
|
334
|
+
? `last sessions.jsonl entry is backfill-stub-only — newest stub started_at ${ledger.iso}`
|
|
335
|
+
: `last sessions.jsonl entry ${ledger.iso}`;
|
|
336
|
+
|
|
229
337
|
const base =
|
|
230
|
-
`sessions-staleness:
|
|
338
|
+
`sessions-staleness: ${ledgerDescription} is ${deltaHours}h behind ` +
|
|
231
339
|
`pre-session events.jsonl activity ${foreign.iso} — possible close-through gap ` +
|
|
232
340
|
`(sessions ended without a ledger record; run node scripts/backfill-abandoned-sessions.mjs --dry-run)`;
|
|
233
341
|
|
|
@@ -239,6 +347,7 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
239
347
|
lastLedgerAt: ledger.iso,
|
|
240
348
|
lastForeignEventAt: foreign.iso,
|
|
241
349
|
deltaHours,
|
|
350
|
+
...(ledger.stubFallback ? { stubFallback: true } : {}),
|
|
242
351
|
};
|
|
243
352
|
} catch {
|
|
244
353
|
// Defensive catch-all — banner must never throw.
|