session-orchestrator 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -9,24 +9,182 @@
|
|
|
9
9
|
* per-append overhead is wasteful given ~6 KiB/day growth.
|
|
10
10
|
*
|
|
11
11
|
* Rename safety (POSIX): atomic rename is safe with in-flight writers. Old fds
|
|
12
|
-
* continue writing to the original inode (now
|
|
13
|
-
*
|
|
12
|
+
* continue writing to the original inode (now the archive); new writers will
|
|
13
|
+
* open the new file on next append.
|
|
14
|
+
*
|
|
15
|
+
* ## The ledger carries its own break (#1401)
|
|
16
|
+
*
|
|
17
|
+
* Rotation used to be INVISIBLE. It wrote no event and no durable log line —
|
|
18
|
+
* success surfaced only as a `console.error` from the SessionStart hook, whose
|
|
19
|
+
* stderr the harness discards. So a rotation and a DELETED archive produced
|
|
20
|
+
* byte-identical evidence: an events.jsonl that simply starts later than it
|
|
21
|
+
* used to. Measured 2026-09-19: `events.jsonl.1` (53,896 lines, 2026-04-12 →
|
|
22
|
+
* 2026-09-18) was destroyed by a wave subagent's `touch <path> && rm -f <path>`
|
|
23
|
+
* ignore-probe that adopted the existing 10 MB file, and nothing anywhere
|
|
24
|
+
* recorded that the file had ever existed.
|
|
25
|
+
*
|
|
26
|
+
* Since #1401 the FIRST line of every new active file is an
|
|
27
|
+
* `orchestrator.events.rotated` record naming the archive, its size, its line
|
|
28
|
+
* count and its timestamp range. That record is the tombstone: it is what lets
|
|
29
|
+
* {@link readEventsWithRotations} in `events.mjs` report a MISSING archive as a
|
|
30
|
+
* finding instead of silently returning a shorter history.
|
|
31
|
+
*
|
|
32
|
+
* ## Why `_archive/<name>` and not the `.1`..`.N` ring
|
|
33
|
+
*
|
|
34
|
+
* The ring was replaced in #1401, for a structural reason rather than taste:
|
|
35
|
+
* its shift step RENAMES every surviving archive on each rotation, so the
|
|
36
|
+
* `archived_as` pointer above would go stale the moment the next rotation ran
|
|
37
|
+
* and every reader would report a phantom gap. A durable pointer needs a
|
|
38
|
+
* durable name. Three further consequences, all measured or direct:
|
|
39
|
+
*
|
|
40
|
+
* - `_archive/events-<first>_<last>.jsonl` says what it holds; `.1` does not,
|
|
41
|
+
* which is how a 5-month ledger read as scratch to the agent that deleted it.
|
|
42
|
+
* - `.orchestrator/metrics/_archive/` is already gitignored (`.gitignore`) and
|
|
43
|
+
* already the repo's archive convention for ledger data.
|
|
44
|
+
* - Census 2026-09-19 @ 8f15f77b — `rg -n 'jsonl\.1|jsonl\.[0-9]' scripts/ hooks/`
|
|
45
|
+
* (excluding tests) found ZERO code readers of the ring, so nothing in the
|
|
46
|
+
* codebase breaks. Two live fleet repos DO carry a ~10 MB `events.jsonl.1`
|
|
47
|
+
* on disk, which is why the READER still reads the legacy ring; this writer
|
|
48
|
+
* never creates, shifts or prunes one.
|
|
14
49
|
*/
|
|
15
50
|
|
|
16
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
statSync,
|
|
53
|
+
renameSync,
|
|
54
|
+
unlinkSync,
|
|
55
|
+
existsSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
appendFileSync,
|
|
58
|
+
mkdirSync,
|
|
59
|
+
readdirSync,
|
|
60
|
+
} from 'node:fs';
|
|
61
|
+
import path from 'node:path';
|
|
62
|
+
import {
|
|
63
|
+
ARCHIVE_DIR_NAME,
|
|
64
|
+
ARCHIVE_NAME_RE,
|
|
65
|
+
ROTATION_EVENT,
|
|
66
|
+
parseEventLines,
|
|
67
|
+
stampEventSchemaVersion,
|
|
68
|
+
summarizeEventRecords,
|
|
69
|
+
validateEventRecord,
|
|
70
|
+
} from './events-schema.mjs';
|
|
71
|
+
|
|
72
|
+
// The archive naming contract (`ROTATION_EVENT`, `ARCHIVE_DIR_NAME`,
|
|
73
|
+
// `ARCHIVE_NAME_RE`, `LEGACY_RING_MAX`) is defined in `events-schema.mjs`, the
|
|
74
|
+
// zero-fs module the reader in `events.mjs` also loads — see the comment there
|
|
75
|
+
// for the hook-closure measurement that put it on that side.
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `2026-04-12T06:33:01.123Z` → `20260412T063301Z` — filename-safe, and
|
|
79
|
+
* lexicographically ordered, which is what lets the pruner sort archives
|
|
80
|
+
* chronologically by name alone.
|
|
81
|
+
* @param {string} iso
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function compactStamp(iso) {
|
|
85
|
+
return iso.replace(/\.\d+/, '').replace(/[-:]/g, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* An archive path inside `dir` that does not yet exist.
|
|
90
|
+
*
|
|
91
|
+
* `renameSync` overwrites its destination SILENTLY, so a name collision would
|
|
92
|
+
* destroy an existing archive — the exact loss class #1401 exists to prevent.
|
|
93
|
+
* Exhausting the suffix range therefore THROWS rather than returning a path
|
|
94
|
+
* that would overwrite: the caller's catch turns that into `reason: 'error'`,
|
|
95
|
+
* leaving the active log in place and losing nothing.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} dir
|
|
98
|
+
* @param {string|null} firstTs
|
|
99
|
+
* @param {string|null} lastTs
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function uniqueArchivePath(dir, firstTs, lastTs) {
|
|
103
|
+
const from = firstTs ? compactStamp(firstTs) : 'unknown';
|
|
104
|
+
const to = lastTs ? compactStamp(lastTs) : compactStamp(new Date().toISOString());
|
|
105
|
+
const base = `events-${from}_${to}`;
|
|
106
|
+
let candidate = path.join(dir, `${base}.jsonl`);
|
|
107
|
+
// Ceiling (BV-004): 999 same-range archives. Rotation fires 2-3x/year at the
|
|
108
|
+
// measured ~6 KiB/day growth, and the range is content-derived, so a second
|
|
109
|
+
// collision already implies something is re-rotating identical content.
|
|
110
|
+
// Revisit if a `pruned`-less archive dir is ever seen holding a `-3` suffix.
|
|
111
|
+
for (let n = 2; existsSync(candidate); n += 1) {
|
|
112
|
+
if (n > 999) {
|
|
113
|
+
throw new Error(`events-rotation: no free archive name for ${base} in ${dir}`);
|
|
114
|
+
}
|
|
115
|
+
candidate = path.join(dir, `${base}-${n}.jsonl`);
|
|
116
|
+
}
|
|
117
|
+
return candidate;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Enforce `maxBackups` over the archives in `dir`, oldest first.
|
|
122
|
+
*
|
|
123
|
+
* The cap is kept rather than dropped because `events-rotation.max-backups` is
|
|
124
|
+
* a live, documented config key: a key whose reader silently stops enforcing it
|
|
125
|
+
* is the "config key nobody produces" defect in reverse. At the measured
|
|
126
|
+
* rotation rate `max-backups: 5` is roughly two years of history.
|
|
127
|
+
*
|
|
128
|
+
* Only names matching {@link ARCHIVE_NAME_RE} are eligible, and the archive
|
|
129
|
+
* just written is never a candidate. A failed unlink is swallowed: leaving one
|
|
130
|
+
* archive too many is strictly better than aborting a completed rotation.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} dir
|
|
133
|
+
* @param {number} maxBackups
|
|
134
|
+
* @param {string} keepPath — absolute path of the archive written by this run.
|
|
135
|
+
* @returns {string[]} absolute paths actually deleted.
|
|
136
|
+
*/
|
|
137
|
+
function pruneArchives(dir, maxBackups, keepPath) {
|
|
138
|
+
const pruned = [];
|
|
139
|
+
let names;
|
|
140
|
+
try {
|
|
141
|
+
names = readdirSync(dir).filter((name) => ARCHIVE_NAME_RE.test(name)).sort();
|
|
142
|
+
} catch {
|
|
143
|
+
return pruned;
|
|
144
|
+
}
|
|
145
|
+
// `sort()` orders by the leading `YYYYMMDDTHHMMSSZ` first stamp, i.e. oldest
|
|
146
|
+
// first. An `unknown_` prefix sorts AFTER every digit, so an archive whose
|
|
147
|
+
// range could not be derived is treated as newest and outlives the dated ones
|
|
148
|
+
// — conservative on purpose: never delete the file you understand least.
|
|
149
|
+
const excess = names.length - maxBackups;
|
|
150
|
+
for (let i = 0; i < excess; i += 1) {
|
|
151
|
+
const victim = path.join(dir, names[i]);
|
|
152
|
+
if (victim === keepPath) continue;
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(victim);
|
|
155
|
+
pruned.push(victim);
|
|
156
|
+
} catch {
|
|
157
|
+
/* keep it — an un-prunable archive is not a rotation failure */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return pruned;
|
|
161
|
+
}
|
|
17
162
|
|
|
18
163
|
/**
|
|
19
164
|
* Rotate the events log if it exceeds `maxSizeMb`.
|
|
20
165
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
166
|
+
* On rotation the active file is renamed into
|
|
167
|
+
* `<dir>/_archive/events-<firstTs>_<lastTs>.jsonl` and an
|
|
168
|
+
* `orchestrator.events.rotated` record is appended to the now-absent active
|
|
169
|
+
* path, making it that file's first line. Archives beyond `maxBackups` are
|
|
170
|
+
* pruned (oldest first) and named in the record's `pruned` field, so the
|
|
171
|
+
* deletion is itself in the ledger.
|
|
172
|
+
*
|
|
173
|
+
* The record is written SYNCHRONOUSLY here rather than via `emitEvent()` for
|
|
174
|
+
* two reasons: it must land between the rename and any other writer's first
|
|
175
|
+
* append to be the first line, and `emitEvent()`'s async correlation lookups
|
|
176
|
+
* (session lock, wave manifest) describe a session, not a file operation. It
|
|
177
|
+
* still goes through the schema module's own stamper and validator, so it is
|
|
178
|
+
* not a raw writer inventing a shape.
|
|
23
179
|
*
|
|
24
180
|
* @param {object} opts
|
|
25
181
|
* @param {string} opts.logPath — absolute path to `events.jsonl`
|
|
26
182
|
* @param {number} opts.maxSizeMb — integer 1..1024
|
|
27
183
|
* @param {number} opts.maxBackups — integer 1..20
|
|
28
184
|
* @param {boolean} opts.enabled — if false, returns early
|
|
29
|
-
* @returns {{rotated: boolean, reason?: string, archivedAs?: string, sizeBefore?: number,
|
|
185
|
+
* @returns {{rotated: boolean, reason?: string, archivedAs?: string, sizeBefore?: number,
|
|
186
|
+
* maxBackups?: number, lines?: number, firstTs?: string|null, lastTs?: string|null,
|
|
187
|
+
* malformedLines?: number, pruned?: string[], recordWritten?: boolean, error?: string}}
|
|
30
188
|
*/
|
|
31
189
|
export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
|
|
32
190
|
// --- Input validation (throw — programmer error, not runtime fs failure) ---
|
|
@@ -44,43 +202,81 @@ export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
|
|
|
44
202
|
return { rotated: false, reason: 'disabled' };
|
|
45
203
|
}
|
|
46
204
|
|
|
205
|
+
// Set once the rename has happened. After that point the 10 MB HAS moved, so
|
|
206
|
+
// a later failure must never be reported as `rotated: false` — a caller that
|
|
207
|
+
// believes nothing happened is exactly how a rotation goes unnoticed.
|
|
208
|
+
let archivedAs = null;
|
|
209
|
+
let sizeBefore = 0;
|
|
210
|
+
|
|
47
211
|
try {
|
|
48
212
|
if (!existsSync(logPath)) {
|
|
49
213
|
return { rotated: false, reason: 'no-file' };
|
|
50
214
|
}
|
|
51
215
|
|
|
52
|
-
|
|
216
|
+
sizeBefore = statSync(logPath).size;
|
|
53
217
|
const threshold = maxSizeMb * 1024 * 1024;
|
|
54
218
|
if (sizeBefore < threshold) {
|
|
55
219
|
return { rotated: false, reason: 'under-threshold' };
|
|
56
220
|
}
|
|
57
221
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
222
|
+
// Read BEFORE the rename: the content is what names the archive. Cost
|
|
223
|
+
// ceiling (BV-004): one full read of a file at the rotation threshold —
|
|
224
|
+
// ~50 ms and ~40 MB transient at the default 10 MB cap, paid 2-3x/year.
|
|
225
|
+
// Revisit if `max-size-mb` is ever raised past ~100.
|
|
226
|
+
const { records, malformedLines } = parseEventLines(readFileSync(logPath, 'utf8'));
|
|
227
|
+
const { firstTs, lastTs } = summarizeEventRecords(records);
|
|
228
|
+
const lines = records.length + malformedLines;
|
|
63
229
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const dst = `${logPath}.${i + 1}`;
|
|
68
|
-
if (existsSync(src)) {
|
|
69
|
-
renameSync(src, dst);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
230
|
+
const archiveDir = path.join(path.dirname(logPath), ARCHIVE_DIR_NAME);
|
|
231
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
232
|
+
const destination = uniqueArchivePath(archiveDir, firstTs, lastTs);
|
|
72
233
|
|
|
73
|
-
|
|
74
|
-
|
|
234
|
+
renameSync(logPath, destination);
|
|
235
|
+
archivedAs = destination;
|
|
236
|
+
|
|
237
|
+
const pruned = pruneArchives(archiveDir, maxBackups, archivedAs);
|
|
238
|
+
|
|
239
|
+
// The tombstone. `first_ts` / `last_ts` are `null` — present, never absent —
|
|
240
|
+
// when the archive held no parseable timestamp: a reader must be able to
|
|
241
|
+
// tell "range unknown" from "field not written by this version".
|
|
242
|
+
const record = stampEventSchemaVersion({
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
event: ROTATION_EVENT,
|
|
245
|
+
archived_as: archivedAs,
|
|
246
|
+
size_before: sizeBefore,
|
|
247
|
+
lines,
|
|
248
|
+
first_ts: firstTs,
|
|
249
|
+
last_ts: lastTs,
|
|
250
|
+
malformed_lines: malformedLines,
|
|
251
|
+
...(pruned.length > 0 ? { pruned } : {}),
|
|
252
|
+
});
|
|
253
|
+
const verdict = validateEventRecord(record);
|
|
254
|
+
let recordWritten = false;
|
|
255
|
+
if (verdict.valid) {
|
|
256
|
+
appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
257
|
+
recordWritten = true;
|
|
258
|
+
}
|
|
75
259
|
|
|
76
260
|
return {
|
|
77
261
|
rotated: true,
|
|
78
|
-
archivedAs
|
|
262
|
+
archivedAs,
|
|
79
263
|
sizeBefore,
|
|
80
264
|
maxBackups,
|
|
265
|
+
lines,
|
|
266
|
+
firstTs,
|
|
267
|
+
lastTs,
|
|
268
|
+
malformedLines,
|
|
269
|
+
pruned,
|
|
270
|
+
recordWritten,
|
|
271
|
+
...(recordWritten ? {} : { error: `invalid rotation record: ${verdict.errors.join('; ')}` }),
|
|
81
272
|
};
|
|
82
273
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
|
|
274
|
+
const message = err?.message ?? String(err);
|
|
275
|
+
// Never throw — rotation failure must not break session-start. But once the
|
|
276
|
+
// rename landed, the archive EXISTS and the caller must hear about it.
|
|
277
|
+
if (archivedAs !== null) {
|
|
278
|
+
return { rotated: true, archivedAs, sizeBefore, maxBackups, recordWritten: false, error: message };
|
|
279
|
+
}
|
|
280
|
+
return { rotated: false, reason: 'error', error: message };
|
|
85
281
|
}
|
|
86
282
|
}
|
|
@@ -63,6 +63,42 @@ export function stampEventSchemaVersion(record) {
|
|
|
63
63
|
/** ISO-8601 UTC timestamp with trailing Z (e.g. 2026-05-28T14:35:13.123Z). */
|
|
64
64
|
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
65
65
|
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Rotation-archive naming contract (#1401)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
//
|
|
70
|
+
// These four live HERE, in the pure module, and not beside the rotation writer
|
|
71
|
+
// that produces them — deliberately, and measured. `events-rotation.mjs`
|
|
72
|
+
// imports `node:fs`; when `events.mjs` (the reader) imported the constants
|
|
73
|
+
// from it, `generate-hook-import-set.mjs` went from `reachable_from:
|
|
74
|
+
// ["on-session-start.mjs"]` to SEVENTEEN hooks, including the per-Edit/per-Bash
|
|
75
|
+
// hot paths (`enforce-scope`, `pre-task-scope-disjoint`, `post-edit-validate`).
|
|
76
|
+
// A naming CONTRACT shared by a writer and a reader belongs in the zero-fs
|
|
77
|
+
// module both already load; only the fs code stays behind. Same reasoning as
|
|
78
|
+
// `session-lock-shape.mjs` (`.claude/rules/identity-and-locks.md`).
|
|
79
|
+
|
|
80
|
+
/** Event name written as the first line of the new active file after a rotation. */
|
|
81
|
+
export const ROTATION_EVENT = 'orchestrator.events.rotated';
|
|
82
|
+
|
|
83
|
+
/** Directory (beside the active log) that holds rotated archives. */
|
|
84
|
+
export const ARCHIVE_DIR_NAME = '_archive';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Exact shape of a rotation archive: `events-<first>_<last>.jsonl`, each stamp
|
|
88
|
+
* `YYYYMMDDTHHMMSSZ`, with an optional `-<n>` collision suffix.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately tight, because the same regex decides what the pruner may
|
|
91
|
+
* DELETE. `_archive/` is a shared human-facing directory — this repo's own copy
|
|
92
|
+
* already holds a hand-placed `events-worktree-vault-session-analysis-<date>.jsonl`
|
|
93
|
+
* — and a loose `^events-.*\.jsonl$` would both merge that file into the ledger
|
|
94
|
+
* timeline and offer it up for pruning. Rotation deletes only what it made.
|
|
95
|
+
*/
|
|
96
|
+
export const ARCHIVE_NAME_RE =
|
|
97
|
+
/^events-(?:\d{8}T\d{6}Z|unknown)_\d{8}T\d{6}Z(?:-\d+)?\.jsonl$/;
|
|
98
|
+
|
|
99
|
+
/** Upper bound of the legacy `.1`..`.N` ring (`max-backups` is capped at 20). */
|
|
100
|
+
export const LEGACY_RING_MAX = 20;
|
|
101
|
+
|
|
66
102
|
/** Prefix marking an orchestrator-owned event. */
|
|
67
103
|
export const ORCHESTRATOR_PREFIX = 'orchestrator.';
|
|
68
104
|
|
|
@@ -89,6 +125,84 @@ export function isIso8601(value) {
|
|
|
89
125
|
);
|
|
90
126
|
}
|
|
91
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Split raw JSONL text into records, COUNTING the lines that could not be read.
|
|
130
|
+
*
|
|
131
|
+
* The counting is the point (#1401). Skipping an unreadable line is right — a
|
|
132
|
+
* torn tail from a killed writer must not abort a whole window read — but
|
|
133
|
+
* skipping it SILENTLY turns a partial result into a clean verdict: a join over
|
|
134
|
+
* the ledger then reports "everything matched" in the very instrument built to
|
|
135
|
+
* surface silent failure. So the count travels with the records and every
|
|
136
|
+
* consumer is expected to carry it into its own report and telemetry (HR-105).
|
|
137
|
+
*
|
|
138
|
+
* What counts as malformed here is UNREADABLE, not invalid: a line that is not
|
|
139
|
+
* JSON, or that parses to something other than a plain object. A readable
|
|
140
|
+
* record that `validateEventRecord()` would reject (bad timestamp, illegal
|
|
141
|
+
* event name) is a DIFFERENT class and is returned in `records` — the two must
|
|
142
|
+
* not be conflated, or a schema violation would hide inside a corruption count.
|
|
143
|
+
* Blank lines are neither: a trailing newline is the normal shape of a JSONL
|
|
144
|
+
* file, so an empty line is skipped without being counted.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} text — raw file contents.
|
|
147
|
+
* @returns {{records: object[], malformedLines: number}}
|
|
148
|
+
*/
|
|
149
|
+
export function parseEventLines(text) {
|
|
150
|
+
const records = [];
|
|
151
|
+
let malformedLines = 0;
|
|
152
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
153
|
+
if (line.trim() === '') continue;
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(line);
|
|
157
|
+
} catch {
|
|
158
|
+
malformedLines += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
162
|
+
malformedLines += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
records.push(parsed);
|
|
166
|
+
}
|
|
167
|
+
return { records, malformedLines };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Earliest and latest VALID timestamp across `records` — `null` for each when
|
|
172
|
+
* no record carries a parseable one.
|
|
173
|
+
*
|
|
174
|
+
* Compared on `Date.parse`, not lexicographically: ISO-8601 UTC strings sort by
|
|
175
|
+
* string order ONLY while their millisecond part is uniform, and this stream
|
|
176
|
+
* carries both spellings (`…:13Z` and `…:13.123Z`). `Z` (0x5A) sorts after `.`
|
|
177
|
+
* (0x2E), so the millisecond-free form of the SAME second would compare as the
|
|
178
|
+
* later instant.
|
|
179
|
+
*
|
|
180
|
+
* `null` means "no parseable timestamp in this set" — an honest absence, never
|
|
181
|
+
* a fabricated epoch.
|
|
182
|
+
*
|
|
183
|
+
* @param {object[]} records
|
|
184
|
+
* @returns {{firstTs: string|null, lastTs: string|null}}
|
|
185
|
+
*/
|
|
186
|
+
export function summarizeEventRecords(records) {
|
|
187
|
+
let firstTs = null;
|
|
188
|
+
let lastTs = null;
|
|
189
|
+
let firstMs = Infinity;
|
|
190
|
+
let lastMs = -Infinity;
|
|
191
|
+
for (const record of records ?? []) {
|
|
192
|
+
if (!isIso8601(record?.timestamp)) continue;
|
|
193
|
+
const ms = Date.parse(record.timestamp);
|
|
194
|
+
if (ms < firstMs) {
|
|
195
|
+
firstMs = ms;
|
|
196
|
+
firstTs = record.timestamp;
|
|
197
|
+
}
|
|
198
|
+
if (ms > lastMs) {
|
|
199
|
+
lastMs = ms;
|
|
200
|
+
lastTs = record.timestamp;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { firstTs, lastTs };
|
|
204
|
+
}
|
|
205
|
+
|
|
92
206
|
/**
|
|
93
207
|
* Validate a single events.jsonl record against the canonical schema.
|
|
94
208
|
*
|