session-orchestrator 3.22.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -14,6 +14,7 @@ import { isRealSession } from '../session-schema/filters.mjs';
|
|
|
14
14
|
import { resolveRepoNamespace } from './namespace.mjs';
|
|
15
15
|
import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2 } from './render-learnings.mjs';
|
|
16
16
|
import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3 } from './render-sessions.mjs';
|
|
17
|
+
import { emitMirrorEvent } from './telemetry.mjs';
|
|
17
18
|
|
|
18
19
|
const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
19
20
|
|
|
@@ -256,8 +257,33 @@ export { deriveRepo } from './namespace.mjs';
|
|
|
256
257
|
* @param {object} [opts.meta] — optional extra fields merged into the emitted JSON
|
|
257
258
|
* (used for quality-gate skips to carry a `reason` field). Callers that omit
|
|
258
259
|
* `meta` get the base JSON shape unchanged.
|
|
260
|
+
* @param {number} [opts.line] — 1-based JSONL line number. When FINITE, this
|
|
261
|
+
* entry also gets one `orchestrator.vault.mirror_completed` ledger record
|
|
262
|
+
* (#1147) — UNLESS `action` is `skipped-noop`, whose per-entry record is
|
|
263
|
+
* suppressed as ledger flood and reported only in the run-level roll-up
|
|
264
|
+
* (#1151; see the gate below). Omitting `line` keeps the stdout-only
|
|
265
|
+
* behaviour, which is what the direct unit tests of this function exercise.
|
|
266
|
+
* @param {boolean} [opts.dryRun] — the run's dry-run flag, for telemetry only.
|
|
267
|
+
* @param {string} [opts.skipClass] — `validation` | `mapper-crash`; telemetry only.
|
|
268
|
+
* @param {string} [opts.reason] — explicit telemetry reason. Defaults to
|
|
269
|
+
* `meta.reason` when that exists, so the quality-gate strings
|
|
270
|
+
* (`confidence:X < min:Y` / `narrative:N < min:M` / `status:…`) are REUSED
|
|
271
|
+
* rather than recomputed — recomputing is how one fact becomes two copies.
|
|
272
|
+
* @returns {Promise<string>} the `action` string, so the CLI's main loop can
|
|
273
|
+
* tally a run-level denominator without a second census of these call sites.
|
|
259
274
|
*/
|
|
260
|
-
export function emitAction({
|
|
275
|
+
export async function emitAction({
|
|
276
|
+
action,
|
|
277
|
+
path,
|
|
278
|
+
kind,
|
|
279
|
+
id,
|
|
280
|
+
vaultDir,
|
|
281
|
+
meta,
|
|
282
|
+
line,
|
|
283
|
+
dryRun,
|
|
284
|
+
skipClass,
|
|
285
|
+
reason,
|
|
286
|
+
}) {
|
|
261
287
|
let rel;
|
|
262
288
|
if (path === null || path === undefined) {
|
|
263
289
|
rel = null;
|
|
@@ -271,7 +297,59 @@ export function emitAction({ action, path, kind, id, vaultDir, meta }) {
|
|
|
271
297
|
if (meta && typeof meta === 'object') {
|
|
272
298
|
Object.assign(payload, meta);
|
|
273
299
|
}
|
|
300
|
+
// The stdout JSON-per-entry protocol is the CONTRACT other callers parse —
|
|
301
|
+
// it stays byte-identical; the ledger record below is purely additive.
|
|
274
302
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
303
|
+
|
|
304
|
+
// Per-entry ledger record for every action EXCEPT `skipped-noop` (#1151).
|
|
305
|
+
//
|
|
306
|
+
// `skipped-noop` is the STEADY STATE, not an event: on a populated vault
|
|
307
|
+
// nearly every record is already mirrored, so one `--kind session` run over
|
|
308
|
+
// this repo's ~276-record sessions ledger wrote ~276 per-entry records that
|
|
309
|
+
// between them said "nothing happened" — burying every other event class in
|
|
310
|
+
// the same file. No information is lost by dropping them: `finishRun()` in
|
|
311
|
+
// `scripts/vault-mirror.mjs` counts this class into the run event's `skipped`
|
|
312
|
+
// total AND names it in `action_breakdown['skipped-noop']`, so the noop COUNT
|
|
313
|
+
// stays measured per run and the gate stays falsifiable (HR-105).
|
|
314
|
+
//
|
|
315
|
+
// BV-004 ceiling: what this drops is the per-LINE locator for noops — the
|
|
316
|
+
// ledger can still say HOW MANY records were unchanged, never WHICH. Revisit
|
|
317
|
+
// if a consumer ever needs to name them (a staleness audit over unchanged
|
|
318
|
+
// notes would); reinstate it behind an opt-in flag then, not unconditionally.
|
|
319
|
+
if (Number.isFinite(line) && action !== 'skipped-noop') {
|
|
320
|
+
const telemetryReason =
|
|
321
|
+
reason ?? (meta && typeof meta.reason === 'string' ? meta.reason : undefined);
|
|
322
|
+
await emitMirrorEvent({
|
|
323
|
+
action,
|
|
324
|
+
kind,
|
|
325
|
+
line,
|
|
326
|
+
recordId: id,
|
|
327
|
+
// The VAULT-RELATIVE path (`rel`), never the absolute one: the ledger
|
|
328
|
+
// record can travel over the optional Clank webhook unredacted.
|
|
329
|
+
path: rel,
|
|
330
|
+
skipClass,
|
|
331
|
+
reason: telemetryReason,
|
|
332
|
+
dryRun,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return action;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Per-entry stdout + telemetry wrapper used by {@link processLearning} and
|
|
341
|
+
* {@link processSession}. Threads the line number, kind, vaultDir and dry-run
|
|
342
|
+
* flag out of the processor `ctx` so the 18 call sites do not repeat them.
|
|
343
|
+
*
|
|
344
|
+
* @param {number} lineNum — 1-based JSONL line number of the entry.
|
|
345
|
+
* @param {object} ctx — processor context (`vaultDir`, `dryRun`, `kind`).
|
|
346
|
+
* @param {object} opts — the remaining {@link emitAction} fields (`action`,
|
|
347
|
+
* `path`, `id`, and optionally `meta`).
|
|
348
|
+
* @returns {Promise<string>} the `action` string.
|
|
349
|
+
*/
|
|
350
|
+
function emitEntryAction(lineNum, ctx, opts) {
|
|
351
|
+
const { vaultDir, dryRun, kind } = ctx;
|
|
352
|
+
return emitAction({ ...opts, vaultDir, kind, line: lineNum, dryRun });
|
|
275
353
|
}
|
|
276
354
|
|
|
277
355
|
// ── Secret masking (#974) — THE choke-point ───────────────────────────────────
|
|
@@ -447,7 +525,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
447
525
|
const {
|
|
448
526
|
vaultDir,
|
|
449
527
|
dryRun,
|
|
450
|
-
kind,
|
|
528
|
+
kind: _kind, // threaded to emitAction via ctx by emitEntryAction (#1147); kept for ctx symmetry
|
|
451
529
|
force = false,
|
|
452
530
|
qualityMinConfidence = 0.5,
|
|
453
531
|
qualityMinNarrativeChars: _qualityMinNarrativeChars = 400, // unused for learnings; kept for ctx symmetry
|
|
@@ -531,15 +609,12 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
531
609
|
// Missing/non-numeric confidence is treated as 1.0 (legacy entries pass).
|
|
532
610
|
const learningConfidence = typeof entry.confidence === 'number' ? entry.confidence : 1.0;
|
|
533
611
|
if (learningConfidence < qualityMinConfidence) {
|
|
534
|
-
|
|
612
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
535
613
|
action: 'skipped-quality-low',
|
|
536
614
|
path: null,
|
|
537
|
-
kind,
|
|
538
615
|
id: entryId,
|
|
539
|
-
vaultDir,
|
|
540
616
|
meta: { reason: `confidence:${learningConfidence} < min:${qualityMinConfidence}` },
|
|
541
617
|
});
|
|
542
|
-
return;
|
|
543
618
|
}
|
|
544
619
|
|
|
545
620
|
// #660: namespace new writes under a per-repo subdirectory.
|
|
@@ -572,8 +647,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
572
647
|
// Render the candidate and compare canonical fields before deciding to skip.
|
|
573
648
|
const candidateContent = generator(entry, slug, generatorOpts);
|
|
574
649
|
if (learningContentMatches(legacyContent, candidateContent)) {
|
|
575
|
-
|
|
576
|
-
return;
|
|
650
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
|
|
577
651
|
}
|
|
578
652
|
// Content differs — fall through to write into the namespaced path.
|
|
579
653
|
}
|
|
@@ -588,15 +662,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
588
662
|
if (!fm || !fm['_generator']) {
|
|
589
663
|
// Hand-written: skip
|
|
590
664
|
process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
|
|
591
|
-
|
|
592
|
-
return;
|
|
665
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
|
|
593
666
|
}
|
|
594
667
|
|
|
595
668
|
if (fm['_generator'] !== GENERATOR_MARKER) {
|
|
596
669
|
// Different generator — treat as hand-written to be safe
|
|
597
670
|
process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
|
|
598
|
-
|
|
599
|
-
return;
|
|
671
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
|
|
600
672
|
}
|
|
601
673
|
|
|
602
674
|
if (fm['id'] !== slug) {
|
|
@@ -611,16 +683,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
611
683
|
const disambigFm = parseFrontmatter(disambigContent);
|
|
612
684
|
if (!disambigFm || !disambigFm['_generator']) {
|
|
613
685
|
process.stderr.write(`SKIP hand-written (disambig): ${targetPath}\n`);
|
|
614
|
-
|
|
615
|
-
return;
|
|
686
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
|
|
616
687
|
}
|
|
617
688
|
// Check updated advancement; if date has not advanced, also diff content.
|
|
618
689
|
const entryUpdated = toDate(dateSource);
|
|
619
690
|
if (disambigFm['updated'] && disambigFm['updated'] >= entryUpdated) {
|
|
620
691
|
const candidateContent = generator(entry, slug, generatorOpts);
|
|
621
692
|
if (learningContentMatches(disambigContent, candidateContent)) {
|
|
622
|
-
|
|
623
|
-
return;
|
|
693
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: disambigSlug });
|
|
624
694
|
}
|
|
625
695
|
// Content differs — fall through to write.
|
|
626
696
|
}
|
|
@@ -628,8 +698,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
628
698
|
|
|
629
699
|
const content = generator(entry, slug, generatorOpts);
|
|
630
700
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
631
|
-
|
|
632
|
-
return;
|
|
701
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-collision-resolved', path: targetPath, id: slug });
|
|
633
702
|
}
|
|
634
703
|
|
|
635
704
|
// Same id: check if updated would advance (unless --force overrides).
|
|
@@ -639,8 +708,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
639
708
|
if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
640
709
|
const candidateContent = generator(entry, slug, generatorOpts);
|
|
641
710
|
if (learningContentMatches(existingContent, candidateContent)) {
|
|
642
|
-
|
|
643
|
-
return;
|
|
711
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
|
|
644
712
|
}
|
|
645
713
|
// Content differs — fall through to overwrite (same path as date-advance branch).
|
|
646
714
|
}
|
|
@@ -648,21 +716,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
648
716
|
// Overwrite with advanced updated date (or forced re-render)
|
|
649
717
|
const content = generator(entry, slug, generatorOpts);
|
|
650
718
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
651
|
-
|
|
652
|
-
return;
|
|
719
|
+
return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: slug });
|
|
653
720
|
}
|
|
654
721
|
|
|
655
722
|
// File does not exist — create
|
|
656
723
|
const content = generator(entry, slug, generatorOpts);
|
|
657
724
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
658
|
-
|
|
725
|
+
return emitEntryAction(_lineNum, ctx, { action: 'created', path: targetPath, id: slug });
|
|
659
726
|
}
|
|
660
727
|
|
|
661
728
|
export async function processSession(rawEntry, _lineNum, ctx) {
|
|
662
729
|
const {
|
|
663
730
|
vaultDir,
|
|
664
731
|
dryRun,
|
|
665
|
-
kind,
|
|
732
|
+
kind: _kind, // threaded to emitAction via ctx by emitEntryAction (#1147); kept for ctx symmetry
|
|
666
733
|
force = false,
|
|
667
734
|
qualityMinNarrativeChars = 400,
|
|
668
735
|
qualityMinConfidence: _qualityMinConfidence = 0.5, // unused for sessions; kept for ctx symmetry
|
|
@@ -716,15 +783,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
716
783
|
// status from the vault; the mapping keeps every OTHER status honest, and
|
|
717
784
|
// guards the generators' other entry point (the render.mjs barrel).
|
|
718
785
|
if (!isRealSession(entry)) {
|
|
719
|
-
|
|
786
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
720
787
|
action: 'skipped-abandoned',
|
|
721
788
|
path: null,
|
|
722
|
-
kind,
|
|
723
789
|
id: session_id,
|
|
724
|
-
vaultDir,
|
|
725
790
|
meta: { reason: `status:${entry?.status}` },
|
|
726
791
|
});
|
|
727
|
-
return;
|
|
728
792
|
}
|
|
729
793
|
|
|
730
794
|
// #732: resolve the leak-guarded repo namespace ONCE per session, BEFORE the
|
|
@@ -752,15 +816,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
752
816
|
const narrativeBody = renderedBody.replace(/^---[\s\S]*?---/m, '').trim();
|
|
753
817
|
const narrativeChars = narrativeBody.length;
|
|
754
818
|
if (narrativeChars < qualityMinNarrativeChars) {
|
|
755
|
-
|
|
819
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
756
820
|
action: 'skipped-quality-low',
|
|
757
821
|
path: null,
|
|
758
|
-
kind,
|
|
759
822
|
id: session_id,
|
|
760
|
-
vaultDir,
|
|
761
823
|
meta: { reason: `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}` },
|
|
762
824
|
});
|
|
763
|
-
return;
|
|
764
825
|
}
|
|
765
826
|
|
|
766
827
|
// #660: namespace new writes under a per-repo subdirectory. repoNs was
|
|
@@ -786,8 +847,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
786
847
|
if (legacyFm && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id) {
|
|
787
848
|
const entryUpdated = toDate(entry.completed_at);
|
|
788
849
|
if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
789
|
-
|
|
790
|
-
return;
|
|
850
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
791
851
|
}
|
|
792
852
|
// Updated date would advance — fall through to write into the namespaced path.
|
|
793
853
|
}
|
|
@@ -800,31 +860,27 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
800
860
|
if (!fm || !fm['_generator']) {
|
|
801
861
|
// Hand-written: skip
|
|
802
862
|
process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
|
|
803
|
-
|
|
804
|
-
return;
|
|
863
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
|
|
805
864
|
}
|
|
806
865
|
|
|
807
866
|
if (fm['_generator'] !== GENERATOR_MARKER) {
|
|
808
867
|
process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
|
|
809
|
-
|
|
810
|
-
return;
|
|
868
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
|
|
811
869
|
}
|
|
812
870
|
|
|
813
871
|
// Same generator: check id and updated
|
|
814
872
|
if (fm['id'] === session_id) {
|
|
815
873
|
const entryUpdated = toDate(entry.completed_at);
|
|
816
874
|
if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
817
|
-
|
|
818
|
-
return;
|
|
875
|
+
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
819
876
|
}
|
|
820
877
|
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|
|
821
|
-
|
|
822
|
-
return;
|
|
878
|
+
return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: session_id });
|
|
823
879
|
}
|
|
824
880
|
}
|
|
825
881
|
|
|
826
882
|
// File does not exist — create. Reuse the rendered body computed during the
|
|
827
883
|
// quality-gate check (avoids a second generator invocation).
|
|
828
884
|
if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
|
|
829
|
-
|
|
885
|
+
return emitEntryAction(_lineNum, ctx, { action: 'created', path: targetPath, id: session_id });
|
|
830
886
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry.mjs — vault-mirror telemetry emitters (#1116, #1073, #1147).
|
|
3
|
+
*
|
|
4
|
+
* Two events, deliberately BOTH:
|
|
5
|
+
*
|
|
6
|
+
* - `orchestrator.vault.mirror_completed` — ONE record per JSONL entry
|
|
7
|
+
* processed (created / updated / every `skipped-*` EXCEPT `skipped-noop`),
|
|
8
|
+
* carrying the same `action` the CLI wrote to stdout for that entry.
|
|
9
|
+
* `skipped-noop` is excluded because it is the steady state of a populated
|
|
10
|
+
* vault, not an event: emitting it made a single run write hundreds of
|
|
11
|
+
* records that between them said nothing happened (#1151). Its count is
|
|
12
|
+
* carried by the run event's `skipped` total and `action_breakdown`, so the
|
|
13
|
+
* class stays measured — only its per-line locators are gone.
|
|
14
|
+
* - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
|
|
15
|
+
* carrying the DENOMINATOR (`total` plus the per-class counts).
|
|
16
|
+
*
|
|
17
|
+
* The per-entry event alone is not enough, and that is the whole point of the
|
|
18
|
+
* pairing: a healthy run over an empty source emits zero per-entry records, and
|
|
19
|
+
* a mirror whose emitter is broken also emits zero — the two are
|
|
20
|
+
* indistinguishable from the ledger (`.claude/rules/host-resources.md` § HR-105,
|
|
21
|
+
* "a rule you cannot falsify is not a rule"). The run event is emitted
|
|
22
|
+
* unconditionally — including on every abort path, where it carries an
|
|
23
|
+
* `aborted` discriminator (`missing-vault-dir` | `vault-not-canonical` |
|
|
24
|
+
* `missing-source` | `malformed-json` | `filesystem-error` |
|
|
25
|
+
* `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
|
|
26
|
+
* LABELLED partial rather than a silent one, and the record's absence — and
|
|
27
|
+
* nothing else — is the broken-emitter signal.
|
|
28
|
+
*
|
|
29
|
+
* Both emitters are best-effort: a telemetry failure must never fail a mirror
|
|
30
|
+
* run, so every emit is wrapped and its rejection swallowed (same posture as the
|
|
31
|
+
* `orchestrator.secret_masker.applied` emit in `scripts/vault-mirror.mjs`).
|
|
32
|
+
*
|
|
33
|
+
* Ledger destination: `emitEvent` is called 2-arg, so every event from one run
|
|
34
|
+
* resolves the SAME destination (`SO_PROJECT_DIR`, i.e. `CLAUDE_PROJECT_DIR` or
|
|
35
|
+
* the CWD walk-up) as the masker emit. This CLI has no repo-root flag and
|
|
36
|
+
* deriving one from `--source` would split a single run's telemetry across two
|
|
37
|
+
* ledgers.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
41
|
+
import { SO_PROJECT_DIR } from '../platform.mjs';
|
|
42
|
+
|
|
43
|
+
/** Canonical event name for a single vault-mirror JSONL entry. */
|
|
44
|
+
export const MIRROR_EVENT = 'orchestrator.vault.mirror_completed';
|
|
45
|
+
|
|
46
|
+
/** Canonical event name for the run-level roll-up (the denominator). */
|
|
47
|
+
export const MIRROR_RUN_EVENT = 'orchestrator.vault.mirror_run_completed';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upper bound (characters) on the `reason` string written into the events
|
|
51
|
+
* ledger. Renderer validation messages are short and structured
|
|
52
|
+
* (`missing required field 'x' (session_id=…)`), quality-gate reasons are
|
|
53
|
+
* shorter still (`confidence:0.4 < min:0.5`), and native mapper-crash messages
|
|
54
|
+
* are bounded in practice — the clamp only stops a pathological message from
|
|
55
|
+
* bloating every ledger line. Revisit if a legitimate reason is ever observed
|
|
56
|
+
* truncated.
|
|
57
|
+
*/
|
|
58
|
+
const MIRROR_REASON_MAX = 300;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* "Absent is not zero" admission test (docs/events-schema.md): a field that was
|
|
62
|
+
* not measured is OMITTED, never written as `0`/`null`. `null` counts as
|
|
63
|
+
* not-measured here — `record_id` is explicitly `null` for a record carrying
|
|
64
|
+
* neither `id` nor `session_id`, and a null `record_id` in the ledger would read
|
|
65
|
+
* as a measured empty id rather than as "this record had none".
|
|
66
|
+
* @param {unknown} v
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
const present = (v) => v !== undefined && v !== null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Session attribution for this CLI's events.
|
|
73
|
+
*
|
|
74
|
+
* The explicit `SO_PROJECT_DIR` argument is LOAD-BEARING, not decoration:
|
|
75
|
+
* `readLock()` (via `lockPathFor`) defaults to `process.cwd()`, NOT to
|
|
76
|
+
* `SO_PROJECT_DIR`. Calling `sessionAttribution()` bare would therefore read the
|
|
77
|
+
* lock of whatever directory the process happens to run in while the ledger line
|
|
78
|
+
* lands under `CLAUDE_PROJECT_DIR` — attributing a record to a session that
|
|
79
|
+
* never wrote it. Same root for both halves or neither.
|
|
80
|
+
*
|
|
81
|
+
* @returns {{session_id?: string, semantic_session_id?: string}}
|
|
82
|
+
*/
|
|
83
|
+
function mirrorSessionAttribution() {
|
|
84
|
+
return sessionAttribution(SO_PROJECT_DIR);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Emit one `orchestrator.vault.mirror_completed` telemetry record for a single
|
|
89
|
+
* mirrored entry. Never throws — a rejected emit is swallowed silently.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} opts
|
|
92
|
+
* @param {string} opts.action — the `action` value the CLI wrote to stdout for
|
|
93
|
+
* this same entry (`created` | `updated` | `skipped-*`).
|
|
94
|
+
* @param {string} opts.kind — `learning` | `session` (the `--kind` flag).
|
|
95
|
+
* @param {number} opts.line — 1-based JSONL line number of the entry. Always
|
|
96
|
+
* measured; the only locator available when the record has no id.
|
|
97
|
+
* @param {string|null} [opts.recordId] — the record's `id` / `session_id`.
|
|
98
|
+
* Omitted from the payload when absent.
|
|
99
|
+
* @param {string|null} [opts.path] — VAULT-RELATIVE target path, when one was
|
|
100
|
+
* resolved. Omitted on `skipped-invalid` and on the pre-path quality skips,
|
|
101
|
+
* which are reached before any target path exists. Relative, never absolute:
|
|
102
|
+
* this payload also travels over the optional Clank webhook with no
|
|
103
|
+
* redaction, and an absolute path leaks the operator's home directory.
|
|
104
|
+
* @param {string} [opts.skipClass] — failure-class discriminator on the invalid
|
|
105
|
+
* branches: `validation` | `mapper-crash`.
|
|
106
|
+
* @param {string} [opts.reason] — the renderer's error message, or the
|
|
107
|
+
* quality-gate `meta.reason` string. Clamped to {@link MIRROR_REASON_MAX}.
|
|
108
|
+
* @param {boolean} [opts.dryRun] — whether this run wrote anything at all.
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
export async function emitMirrorEvent({
|
|
112
|
+
action,
|
|
113
|
+
kind,
|
|
114
|
+
line,
|
|
115
|
+
recordId,
|
|
116
|
+
path,
|
|
117
|
+
skipClass,
|
|
118
|
+
reason,
|
|
119
|
+
dryRun,
|
|
120
|
+
}) {
|
|
121
|
+
try {
|
|
122
|
+
await emitEvent(MIRROR_EVENT, {
|
|
123
|
+
action,
|
|
124
|
+
kind,
|
|
125
|
+
line,
|
|
126
|
+
...(present(recordId) ? { record_id: recordId } : {}),
|
|
127
|
+
...(present(path) ? { path } : {}),
|
|
128
|
+
...(present(skipClass) ? { skip_class: skipClass } : {}),
|
|
129
|
+
...(present(reason) ? { reason: String(reason).slice(0, MIRROR_REASON_MAX) } : {}),
|
|
130
|
+
...(typeof dryRun === 'boolean' ? { dry_run: dryRun } : {}),
|
|
131
|
+
...mirrorSessionAttribution(),
|
|
132
|
+
});
|
|
133
|
+
} catch {
|
|
134
|
+
// Silent no-op — telemetry must never be the reason a mirror run fails.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Emit the single `orchestrator.vault.mirror_run_completed` roll-up for a
|
|
140
|
+
* whole CLI run. Never throws.
|
|
141
|
+
*
|
|
142
|
+
* The five counters (`total`, `created`, `updated`, `skipped`, `failed`) are
|
|
143
|
+
* ALWAYS present, INCLUDING when they are `0` — they are the denominator, and
|
|
144
|
+
* this is the one place where a written zero is the payload rather than a
|
|
145
|
+
* violation of "absent is not zero": each one was measured over the entire run.
|
|
146
|
+
* `action_breakdown` is the opposite: it enumerates only the actions that
|
|
147
|
+
* actually occurred, and a missing key there means zero occurrences (the
|
|
148
|
+
* always-present `total` makes that reading unambiguous).
|
|
149
|
+
*
|
|
150
|
+
* On an ABORTED run the same five counters are still written, and `aborted`
|
|
151
|
+
* is what keeps them honest: it says the run stopped before its tail, so the
|
|
152
|
+
* lines after the abort were never attempted and
|
|
153
|
+
* `created + updated + skipped + failed === total` no longer holds. Without
|
|
154
|
+
* that label a partial denominator would be indistinguishable from a complete
|
|
155
|
+
* one — the same "absent is not zero" failure this event exists to prevent,
|
|
156
|
+
* one level up.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} opts
|
|
159
|
+
* @param {string} opts.kind — `learning` | `session` (the `--kind` flag).
|
|
160
|
+
* @param {number} opts.total — non-blank JSONL entries the run attempted.
|
|
161
|
+
* @param {number} opts.created — entries whose action was `created`.
|
|
162
|
+
* @param {number} opts.updated — entries whose action was `updated`.
|
|
163
|
+
* @param {number} opts.skipped — entries skipped for a NON-failure reason
|
|
164
|
+
* (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
|
|
165
|
+
* `skipped-collision-resolved`, `skipped-abandoned`).
|
|
166
|
+
* @param {number} opts.failed — entries that produced `skipped-invalid`
|
|
167
|
+
* (validation error or mapper crash). Split out from `skipped` because these
|
|
168
|
+
* are the runs where a session silently ends up WITHOUT its vault note.
|
|
169
|
+
* @param {Record<string, number>} [opts.actionBreakdown] — per-action counts;
|
|
170
|
+
* only actions observed at least once appear.
|
|
171
|
+
* @param {boolean} opts.dryRun — whether this run wrote anything at all.
|
|
172
|
+
* @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
|
|
173
|
+
* Present ONLY when the run exited before its normal tail. OMITTED on a
|
|
174
|
+
* complete run — absent means "ran to the end", never "unknown". The first
|
|
175
|
+
* three values are PRE-LOOP aborts (#1151): the run never reached its first
|
|
176
|
+
* entry, so all five counters are `0` and that zero is the point — without a
|
|
177
|
+
* record, a mirror that never started is indistinguishable from one that was
|
|
178
|
+
* never invoked.
|
|
179
|
+
* @returns {Promise<void>}
|
|
180
|
+
*/
|
|
181
|
+
export async function emitMirrorRunEvent({
|
|
182
|
+
kind,
|
|
183
|
+
total,
|
|
184
|
+
created,
|
|
185
|
+
updated,
|
|
186
|
+
skipped,
|
|
187
|
+
failed,
|
|
188
|
+
actionBreakdown,
|
|
189
|
+
dryRun,
|
|
190
|
+
aborted,
|
|
191
|
+
}) {
|
|
192
|
+
try {
|
|
193
|
+
await emitEvent(MIRROR_RUN_EVENT, {
|
|
194
|
+
kind,
|
|
195
|
+
total,
|
|
196
|
+
created,
|
|
197
|
+
updated,
|
|
198
|
+
skipped,
|
|
199
|
+
failed,
|
|
200
|
+
...(actionBreakdown && Object.keys(actionBreakdown).length > 0
|
|
201
|
+
? { action_breakdown: actionBreakdown }
|
|
202
|
+
: {}),
|
|
203
|
+
dry_run: dryRun,
|
|
204
|
+
...(present(aborted) ? { aborted } : {}),
|
|
205
|
+
...mirrorSessionAttribution(),
|
|
206
|
+
});
|
|
207
|
+
} catch {
|
|
208
|
+
// Silent no-op — telemetry must never be the reason a mirror run fails.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* vault-staleness-banner.mjs — #319
|
|
2
|
+
* vault-staleness-banner.mjs — #319, record-age gate #1159
|
|
3
3
|
* Reads the latest vault-staleness probe record and classifies a banner
|
|
4
4
|
* severity (warn | alert) for surfacing stale projects in the Meta-Vault.
|
|
5
5
|
*
|
|
@@ -9,11 +9,36 @@
|
|
|
9
9
|
* {timestamp, probe, project_root, vault_dir, scanned_projects,
|
|
10
10
|
* stale_count, errors, duration_ms,
|
|
11
11
|
* findings: [{slug, severity, last_sync, delta_hours, flag}, ...]}
|
|
12
|
+
*
|
|
13
|
+
* **One vocabulary, not two (#1158/#1159 review, N3).** The record-age gate's
|
|
14
|
+
* first cut returned `severity: 'info'` for a stale probe record, and the
|
|
15
|
+
* `vault-staleness` registry entry in `scripts/lib/session-start-probes.mjs`
|
|
16
|
+
* then had to remap that to `'warn'` by hand so the banner would actually
|
|
17
|
+
* render (the module-level default there drops anything but warn/alert as
|
|
18
|
+
* `'ok'`, silent). Two files disagreeing about what severity the SAME record
|
|
19
|
+
* carries is the defect, not either value alone — this module now returns
|
|
20
|
+
* `severity: 'warn'` directly for the demoted case, and the registry needs no
|
|
21
|
+
* remap for it. `kind: 'probe-stale'` still carries the "this is a demotion,
|
|
22
|
+
* not a fresh finding" meaning for any consumer that cares.
|
|
12
23
|
*/
|
|
13
24
|
|
|
14
25
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
26
|
import path from 'node:path';
|
|
16
27
|
|
|
28
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Max age (in days) of the last probe record before its stale-project
|
|
32
|
+
* findings are demoted to a `kind: 'probe-stale'` result (#1159; severity
|
|
33
|
+
* stays `'warn'` — see N3 in the header). The vault-sync bridge runs hourly,
|
|
34
|
+
* but the probe itself only runs per `/discovery` invocation or at
|
|
35
|
+
* session-end — so a week with no new record means the PROBE has stopped
|
|
36
|
+
* running, not that the projects it last saw are still stale today. A
|
|
37
|
+
* 47-day-old record was otherwise re-reported as a current finding on every
|
|
38
|
+
* session start.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_RECORD_AGE_DAYS = 7;
|
|
41
|
+
|
|
17
42
|
/**
|
|
18
43
|
* Format a delta_hours number for the banner message.
|
|
19
44
|
* Rounds to 1 decimal; integers render without a trailing ".0".
|
|
@@ -34,21 +59,39 @@ function formatDelta(hours) {
|
|
|
34
59
|
* computes a banner severity classification. Never throws — graceful no-op
|
|
35
60
|
* on any read error, schema mismatch, or empty/zero-stale state.
|
|
36
61
|
*
|
|
37
|
-
* Severity rules (issue #319):
|
|
62
|
+
* Severity rules (issue #319, record-age gate #1159, single-vocabulary N3):
|
|
38
63
|
* - file absent / unreadable / malformed / stale_count === 0 → null (silent)
|
|
39
|
-
* - stale_count > 0 AND
|
|
40
|
-
*
|
|
64
|
+
* - stale_count > 0 AND record.timestamp older than MAX_RECORD_AGE_DAYS
|
|
65
|
+
* → {severity: 'warn', kind: 'probe-stale', ...} (the probe stopped
|
|
66
|
+
* running; the recorded findings are not a current finding — `kind`
|
|
67
|
+
* carries the demotion meaning, `severity` is what every consumer reads)
|
|
68
|
+
* - stale_count > 0 AND missing/unparsable timestamp AND maxDelta <= 48
|
|
69
|
+
* → 'warn' (unchanged pre-#1159 behaviour)
|
|
70
|
+
* - stale_count > 0 AND missing/unparsable timestamp AND maxDelta > 48
|
|
71
|
+
* → 'alert' (cron likely broken; unchanged pre-#1159 behaviour)
|
|
72
|
+
* - stale_count > 0 AND record.timestamp within MAX_RECORD_AGE_DAYS AND
|
|
73
|
+
* maxDelta <= 48 → 'warn'
|
|
74
|
+
* - stale_count > 0 AND record.timestamp within MAX_RECORD_AGE_DAYS AND
|
|
75
|
+
* maxDelta > 48 → 'alert' (cron likely broken)
|
|
41
76
|
*
|
|
42
|
-
* @param {{repoRoot: string}} opts
|
|
77
|
+
* @param {{repoRoot: string, now?: number}} opts `now` is an optional clock
|
|
78
|
+
* seam (epoch ms, defaults to `Date.now()`) so callers can test the
|
|
79
|
+
* age gate deterministically without global fake timers.
|
|
43
80
|
* @returns {null | {
|
|
44
81
|
* severity: 'warn'|'alert',
|
|
45
82
|
* message: string,
|
|
46
83
|
* staleCount: number,
|
|
47
84
|
* maxDeltaHours: number,
|
|
48
85
|
* timestamp: string,
|
|
86
|
+
* } | {
|
|
87
|
+
* severity: 'warn',
|
|
88
|
+
* kind: 'probe-stale',
|
|
89
|
+
* message: string,
|
|
90
|
+
* ageDays: number,
|
|
91
|
+
* timestamp: string,
|
|
49
92
|
* }}
|
|
50
93
|
*/
|
|
51
|
-
export function checkVaultStaleness({ repoRoot } = {}) {
|
|
94
|
+
export function checkVaultStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
52
95
|
try {
|
|
53
96
|
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
54
97
|
|
|
@@ -91,6 +134,33 @@ export function checkVaultStaleness({ repoRoot } = {}) {
|
|
|
91
134
|
|
|
92
135
|
if (staleCount === 0) return null;
|
|
93
136
|
|
|
137
|
+
// Record-age gate (#1159): a parseable timestamp older than
|
|
138
|
+
// MAX_RECORD_AGE_DAYS means the PROBE has not run since, not that the
|
|
139
|
+
// projects it last saw are still stale — demote the FINDING (via `kind`)
|
|
140
|
+
// rather than re-reporting a warn/alert every session, but keep
|
|
141
|
+
// `severity: 'warn'` (N3, #1158/#1159 review) so a caller reading only
|
|
142
|
+
// `severity` still renders it; a second `severity: 'info'` vocabulary
|
|
143
|
+
// that only the registry's remap understood was the two-files-disagree
|
|
144
|
+
// defect this shape replaces. A missing or unparsable timestamp falls
|
|
145
|
+
// through to today's behaviour unchanged.
|
|
146
|
+
const rawTimestamp = record.timestamp;
|
|
147
|
+
const parsedMs = typeof rawTimestamp === 'string' ? Date.parse(rawTimestamp) : NaN;
|
|
148
|
+
if (Number.isFinite(parsedMs)) {
|
|
149
|
+
const ageDays = Math.floor((now - parsedMs) / MS_PER_DAY);
|
|
150
|
+
if (ageDays > MAX_RECORD_AGE_DAYS) {
|
|
151
|
+
return {
|
|
152
|
+
severity: 'warn',
|
|
153
|
+
kind: 'probe-stale',
|
|
154
|
+
message:
|
|
155
|
+
`⚠ vault-staleness: last probe record is ${ageDays} days old ` +
|
|
156
|
+
`(${rawTimestamp}) — the probe has not run since; the recorded ` +
|
|
157
|
+
`${staleCount} stale projects are NOT a current finding.`,
|
|
158
|
+
ageDays,
|
|
159
|
+
timestamp: rawTimestamp,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
94
164
|
// Compute max delta_hours across findings; treat undefined/null/NaN as 0.
|
|
95
165
|
let maxDelta = 0;
|
|
96
166
|
for (const finding of record.findings) {
|