session-orchestrator 5.12.0 → 5.13.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 +2 -2
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +162 -4
- package/README.md +126 -127
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +1 -1
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +90 -17
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +49 -15
- package/docs/session-config-template.md +30 -22
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +139 -49
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -2
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +39 -27
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +2 -2
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +4 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +10 -17
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/skills/_shared/fleet-protocol.md +142 -1
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +94 -11
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -29,6 +29,178 @@ import {
|
|
|
29
29
|
deriveExpiresAt,
|
|
30
30
|
ValidationError,
|
|
31
31
|
} from './schema.mjs';
|
|
32
|
+
import {
|
|
33
|
+
assertStoreRoleUsable,
|
|
34
|
+
resolveStoreRole,
|
|
35
|
+
isCanonicalStore,
|
|
36
|
+
inboxPathFor,
|
|
37
|
+
receiptsPathFor,
|
|
38
|
+
archivePathFor,
|
|
39
|
+
} from './store-role.mjs';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Inbox store role (GitLab #1516)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
//
|
|
45
|
+
// Only the canonical `learnings.jsonl` takes part. A host on the `inbox` role
|
|
46
|
+
// (see store-role.mjs) never rewrites it: appends and rewrite-new records go to
|
|
47
|
+
// the sibling `learnings-inbox.jsonl`, which readers union in and which the
|
|
48
|
+
// `writer` host ingests on its next rewrite, recording each ingested id in
|
|
49
|
+
// `learnings-inbox-ingested.jsonl`.
|
|
50
|
+
//
|
|
51
|
+
// Ceiling (BV-004): the inbox host DROPS updates to existing learnings
|
|
52
|
+
// (confidence boosts, decay, prune) — there is no operation-receipt contract,
|
|
53
|
+
// only "new id" records travel. The inbox file is never truncated, because
|
|
54
|
+
// deleting lines from a merge=union file resurrects them on the next merge;
|
|
55
|
+
// receipts hide ingested records instead. Compaction is future work.
|
|
56
|
+
// Revisit-trigger: the inbox file exceeds 1 MiB, or a resurrected record is
|
|
57
|
+
// measured.
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Raw text of `filePath`, or `null` when the file does not exist.
|
|
61
|
+
* @param {string} filePath
|
|
62
|
+
* @returns {Promise<string|null>}
|
|
63
|
+
*/
|
|
64
|
+
async function readRawOrNull(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
return await readFile(filePath, 'utf8');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err?.code === 'ENOENT') return null;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ids of every JSONL line in `raw` that parses to an object with a non-empty
|
|
75
|
+
* string `id`. Malformed lines are tolerated (skipped).
|
|
76
|
+
* @param {string|null} raw
|
|
77
|
+
* @returns {Set<string>}
|
|
78
|
+
*/
|
|
79
|
+
function idsOfJsonl(raw) {
|
|
80
|
+
const ids = new Set();
|
|
81
|
+
if (!raw) return ids;
|
|
82
|
+
for (const line of raw.split('\n')) {
|
|
83
|
+
if (line.trim().length === 0) continue;
|
|
84
|
+
try {
|
|
85
|
+
const rec = JSON.parse(line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
|
|
86
|
+
if (rec && typeof rec.id === 'string' && rec.id.length > 0) ids.add(rec.id);
|
|
87
|
+
} catch {
|
|
88
|
+
// another file's malformed line — not ours to report
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return ids;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The inbox view of a canonical store: which inbox records are visible beside
|
|
96
|
+
* `liveEntries`. Returns `null` when `filePath` is not the canonical store or
|
|
97
|
+
* no inbox file exists — callers then behave exactly as before #1516.
|
|
98
|
+
*
|
|
99
|
+
* A record is visible iff it has a non-empty string `id` that is not in the
|
|
100
|
+
* live entries, the archive or the receipts, and it is the first inbox record
|
|
101
|
+
* with that id.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} filePath
|
|
104
|
+
* @param {object[]} liveEntries
|
|
105
|
+
* @returns {Promise<null | {visible: object[], visibleIds: Set<string>,
|
|
106
|
+
* unreceiptedArchivedIds: Set<string>, unreceiptedLiveIds: Set<string>,
|
|
107
|
+
* counts: {visible: number, hidden: number, malformed: number}, tokenText: string}>}
|
|
108
|
+
*/
|
|
109
|
+
async function readInboxView(filePath, liveEntries) {
|
|
110
|
+
if (!isCanonicalStore(filePath)) return null;
|
|
111
|
+
const inboxRaw = await readRawOrNull(inboxPathFor(filePath));
|
|
112
|
+
if (inboxRaw === null) return null;
|
|
113
|
+
const receiptsRaw = (await readRawOrNull(receiptsPathFor(filePath))) ?? '';
|
|
114
|
+
const archiveRaw = (await readRawOrNull(archivePathFor(filePath))) ?? '';
|
|
115
|
+
const receiptedIds = idsOfJsonl(receiptsRaw);
|
|
116
|
+
const archivedIds = idsOfJsonl(archiveRaw);
|
|
117
|
+
const excluded = new Set([...receiptedIds, ...archivedIds]);
|
|
118
|
+
for (const e of liveEntries) {
|
|
119
|
+
if (typeof e?.id === 'string' && e.id.length > 0) excluded.add(e.id);
|
|
120
|
+
}
|
|
121
|
+
const parsed = parseLearningsText(inboxRaw);
|
|
122
|
+
const visible = [];
|
|
123
|
+
const visibleIds = new Set();
|
|
124
|
+
// Inbox ids the LOCAL archive hides but no receipt covers: a writer that
|
|
125
|
+
// died between its archive append and its receipt (expiry-sweep.mjs
|
|
126
|
+
// archiveThenRewrite) left them so. The archive is not synced, so the inbox
|
|
127
|
+
// host still shows them; the next writer rewrite receipts them.
|
|
128
|
+
const unreceiptedArchivedIds = new Set();
|
|
129
|
+
// Inbox ids already LIVE but not receipted: a writer that died between its
|
|
130
|
+
// rename and its receipt (rewriteLearnings) left them so. Without a receipt
|
|
131
|
+
// a later removal from live (no archive) would resurrect them from the inbox.
|
|
132
|
+
const liveIds = new Set(
|
|
133
|
+
liveEntries.map((e) => e?.id).filter((id) => typeof id === 'string' && id.length > 0)
|
|
134
|
+
);
|
|
135
|
+
const unreceiptedLiveIds = new Set();
|
|
136
|
+
for (const e of parsed.entries) {
|
|
137
|
+
const id = e?.id;
|
|
138
|
+
if (typeof id !== 'string' || id.length === 0) continue;
|
|
139
|
+
if (archivedIds.has(id) && !receiptedIds.has(id)) unreceiptedArchivedIds.add(id);
|
|
140
|
+
if (liveIds.has(id) && !receiptedIds.has(id)) unreceiptedLiveIds.add(id);
|
|
141
|
+
if (excluded.has(id) || visibleIds.has(id)) continue;
|
|
142
|
+
visibleIds.add(id);
|
|
143
|
+
visible.push(e);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
visible,
|
|
147
|
+
visibleIds,
|
|
148
|
+
unreceiptedArchivedIds,
|
|
149
|
+
unreceiptedLiveIds,
|
|
150
|
+
counts: {
|
|
151
|
+
visible: visible.length,
|
|
152
|
+
hidden: parsed.entries.length - visible.length,
|
|
153
|
+
malformed: parsed.malformed.length,
|
|
154
|
+
},
|
|
155
|
+
tokenText: `\u0000${inboxRaw}\u0000${receiptsRaw}\u0000${archiveRaw}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Write inbox receipts (#1516) for `ids` — the single receipt writer, used for
|
|
161
|
+
* ids the writer carries into the live store AND for ids it archives straight
|
|
162
|
+
* from the inbox. The archive is not synced between hosts, so without a
|
|
163
|
+
* receipt an archived inbox record turns visible again on the inbox host.
|
|
164
|
+
*
|
|
165
|
+
* No-op unless `storePath` is the canonical store and its inbox file exists.
|
|
166
|
+
* Only ids present in the inbox file and not yet receipted get a line, so a
|
|
167
|
+
* repeated call never duplicates a receipt. The caller MUST hold the store
|
|
168
|
+
* lock (`withLearningsLock(storePath, …)`): the read-filter-append below is
|
|
169
|
+
* not atomic on its own.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} storePath — the canonical `learnings.jsonl`
|
|
172
|
+
* @param {Iterable<string>} ids
|
|
173
|
+
* @returns {Promise<number>} receipt lines appended
|
|
174
|
+
*/
|
|
175
|
+
export async function appendInboxReceipts(storePath, ids) {
|
|
176
|
+
if (!isCanonicalStore(storePath)) return 0;
|
|
177
|
+
const inboxRaw = await readRawOrNull(inboxPathFor(storePath));
|
|
178
|
+
if (inboxRaw === null) return 0;
|
|
179
|
+
const inInbox = idsOfJsonl(inboxRaw);
|
|
180
|
+
const receiptsPath = receiptsPathFor(storePath);
|
|
181
|
+
const receipted = idsOfJsonl((await readRawOrNull(receiptsPath)) ?? '');
|
|
182
|
+
const fresh = [...new Set(ids)].filter(
|
|
183
|
+
(id) => typeof id === 'string' && id.length > 0 && inInbox.has(id) && !receipted.has(id)
|
|
184
|
+
);
|
|
185
|
+
if (fresh.length === 0) return 0;
|
|
186
|
+
const ingestedAt = new Date().toISOString();
|
|
187
|
+
await appendWithTornTailGuard(
|
|
188
|
+
receiptsPath,
|
|
189
|
+
fresh.map((id) => `${JSON.stringify({ id, ingested_at: ingestedAt })}\n`).join('')
|
|
190
|
+
);
|
|
191
|
+
return fresh.length;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Append newline-terminated `text` to `filePath`, first closing a torn last
|
|
196
|
+
* line (#1489). Caller holds the lock guarding `filePath`.
|
|
197
|
+
* @param {string} filePath
|
|
198
|
+
* @param {string} text
|
|
199
|
+
*/
|
|
200
|
+
async function appendWithTornTailGuard(filePath, text) {
|
|
201
|
+
const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
|
|
202
|
+
await appendFile(filePath, lead + text, 'utf8');
|
|
203
|
+
}
|
|
32
204
|
|
|
33
205
|
// ---------------------------------------------------------------------------
|
|
34
206
|
// Store lock (GitLab #1447 point 8)
|
|
@@ -224,12 +396,23 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
|
|
|
224
396
|
* their raw text preserved in the result's `malformed` array, and their
|
|
225
397
|
* 1-based line numbers in `malformedLineNumbers` (same order).
|
|
226
398
|
*
|
|
399
|
+
* For the canonical `learnings.jsonl` with a sibling `learnings-inbox.jsonl`
|
|
400
|
+
* (#1516), visible inbox records are appended AFTER the live entries and an
|
|
401
|
+
* `inbox: {visible, hidden, malformed}` count object is added. Inbox malformed
|
|
402
|
+
* lines are never part of `malformed` — they belong to another file. Without
|
|
403
|
+
* an inbox file the result is unchanged (no `inbox` key).
|
|
404
|
+
*
|
|
227
405
|
* @param {string} filePath — absolute or project-relative path to learnings.jsonl
|
|
228
|
-
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]
|
|
406
|
+
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
|
|
407
|
+
* inbox?: {visible: number, hidden: number, malformed: number}}>}
|
|
229
408
|
*/
|
|
230
409
|
export async function readLearnings(filePath) {
|
|
231
|
-
|
|
232
|
-
|
|
410
|
+
const base = existsSync(filePath)
|
|
411
|
+
? parseLearningsText(await readFile(filePath, 'utf8'))
|
|
412
|
+
: { entries: [], malformed: [], malformedLineNumbers: [] };
|
|
413
|
+
const view = await readInboxView(filePath, base.entries);
|
|
414
|
+
if (!view) return base;
|
|
415
|
+
return { ...base, entries: [...base.entries, ...view.visible], inbox: view.counts };
|
|
233
416
|
}
|
|
234
417
|
|
|
235
418
|
/**
|
|
@@ -240,13 +423,25 @@ export async function readLearnings(filePath) {
|
|
|
240
423
|
* or sweep changes it. An absent store reads as empty text — zero records
|
|
241
424
|
* either way, so the two states share a token.
|
|
242
425
|
*
|
|
426
|
+
* With an inbox file beside the canonical store (#1516) the records are the
|
|
427
|
+
* same union {@link readLearnings} returns, and the token also covers the
|
|
428
|
+
* inbox, receipts and archive text, so it changes when any of them changes.
|
|
429
|
+
*
|
|
243
430
|
* @param {string} filePath
|
|
244
431
|
* @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
|
|
245
|
-
* generation: string}>}
|
|
432
|
+
* generation: string, inbox?: {visible: number, hidden: number, malformed: number}}>}
|
|
246
433
|
*/
|
|
247
434
|
export async function readLearningsSnapshot(filePath) {
|
|
248
435
|
const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
|
|
249
|
-
|
|
436
|
+
const base = parseLearningsText(raw);
|
|
437
|
+
const view = await readInboxView(filePath, base.entries);
|
|
438
|
+
if (!view) return { ...base, generation: `sha256:${digestSha256(raw)}` };
|
|
439
|
+
return {
|
|
440
|
+
...base,
|
|
441
|
+
entries: [...base.entries, ...view.visible],
|
|
442
|
+
inbox: view.counts,
|
|
443
|
+
generation: `sha256:${digestSha256(raw + view.tokenText)}`,
|
|
444
|
+
};
|
|
250
445
|
}
|
|
251
446
|
|
|
252
447
|
/**
|
|
@@ -324,6 +519,8 @@ export function parseLearningsText(raw) {
|
|
|
324
519
|
* {@link LEARNINGS_LOCK_TIMEOUT_MS}
|
|
325
520
|
* @returns {Promise<object>} validated entry
|
|
326
521
|
* @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
|
|
522
|
+
* @throws {LearningsStoreRoleError} canonical store while the host's store role
|
|
523
|
+
* is invalid (#1516; nothing appended)
|
|
327
524
|
*/
|
|
328
525
|
export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
|
|
329
526
|
// Ensure created_at is set first — many writers omit it, and expires_at
|
|
@@ -351,16 +548,19 @@ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
|
|
|
351
548
|
// parses back AND re-validates before any append touches disk. Throws
|
|
352
549
|
// ValidationError on a non-round-tripping record — file is left untouched.
|
|
353
550
|
const line = serializeLearningLineChecked(validated);
|
|
551
|
+
// Fail-closed (#1516): an invalid store role refuses before any lock or write.
|
|
552
|
+
assertStoreRoleUsable(filePath);
|
|
553
|
+
// Inbox role (#1516): the canonical store is never written on this host —
|
|
554
|
+
// the record goes to the sibling inbox file under that file's own lock.
|
|
555
|
+
const target =
|
|
556
|
+
isCanonicalStore(filePath) && resolveStoreRole().role === 'inbox'
|
|
557
|
+
? inboxPathFor(filePath)
|
|
558
|
+
: filePath;
|
|
354
559
|
// The lock path resolution creates the parent directory. The torn-tail check
|
|
355
560
|
// reads inside the lock, so no other writer can change the tail in between.
|
|
356
|
-
await withLearningsLock(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
|
|
360
|
-
await appendFile(filePath, lead + line, 'utf8');
|
|
361
|
-
},
|
|
362
|
-
{ timeoutMs: lockTimeoutMs }
|
|
363
|
-
);
|
|
561
|
+
await withLearningsLock(target, () => appendWithTornTailGuard(target, line), {
|
|
562
|
+
timeoutMs: lockTimeoutMs,
|
|
563
|
+
});
|
|
364
564
|
return validated;
|
|
365
565
|
}
|
|
366
566
|
|
|
@@ -527,6 +727,8 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
527
727
|
* @param {object[]} entries
|
|
528
728
|
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
|
|
529
729
|
* @returns {Promise<object[]>} validated entries (always returned, even dryRun)
|
|
730
|
+
* @throws {LearningsStoreRoleError} non-dry rewrite of the canonical store while
|
|
731
|
+
* the host's store role is invalid (#1516; nothing written)
|
|
530
732
|
*/
|
|
531
733
|
export async function rewriteLearnings(
|
|
532
734
|
filePath,
|
|
@@ -560,15 +762,28 @@ export async function rewriteLearnings(
|
|
|
560
762
|
// rewrite, no backup — and hand the validated entries back to the caller.
|
|
561
763
|
if (dryRun) return validated;
|
|
562
764
|
|
|
765
|
+
// Fail-closed (#1516): an invalid store role refuses before any lock or write.
|
|
766
|
+
assertStoreRoleUsable(filePath);
|
|
767
|
+
const canonical = isCanonicalStore(filePath);
|
|
768
|
+
if (canonical && resolveStoreRole().role === 'inbox') {
|
|
769
|
+
await rewriteAsInbox(filePath, validated, lines);
|
|
770
|
+
return validated;
|
|
771
|
+
}
|
|
772
|
+
|
|
563
773
|
// Under the store lock (#1447 point 8) so no append lands between the backup
|
|
564
774
|
// and the rename. The CALLER's read must sit inside the same lock for a
|
|
565
775
|
// read-modify-write to be safe — this call joins it reentrantly.
|
|
566
776
|
await withLearningsLock(filePath, async () => {
|
|
777
|
+
const liveRaw = canonical || malformedLines === undefined ? await readRawOrNull(filePath) : null;
|
|
567
778
|
// Default: the store's own malformed lines as they stand right now, read
|
|
568
779
|
// under the lock that guards the rename below.
|
|
569
780
|
const kept =
|
|
570
|
-
malformedLines ??
|
|
571
|
-
|
|
781
|
+
malformedLines ?? (liveRaw !== null ? parseLearningsText(liveRaw).malformed : []);
|
|
782
|
+
// Inbox records visible BEFORE this rewrite (#1516); those the batch now
|
|
783
|
+
// carries into the live store get a receipt after the rename.
|
|
784
|
+
const inboxView = canonical
|
|
785
|
+
? await readInboxView(filePath, liveRaw !== null ? parseLearningsText(liveRaw).entries : [])
|
|
786
|
+
: null;
|
|
572
787
|
// Malformed lines last, each newline-terminated: a truncated final line
|
|
573
788
|
// otherwise fuses with the next append into one more unparseable line.
|
|
574
789
|
const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
|
|
@@ -591,6 +806,65 @@ export async function rewriteLearnings(
|
|
|
591
806
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
592
807
|
await writeFile(tmp, body, 'utf8');
|
|
593
808
|
await rename(tmp, filePath);
|
|
809
|
+
|
|
810
|
+
// Receipts (#1516): an ingested inbox record stays hidden even if a later
|
|
811
|
+
// rewrite drops it from the live store without archiving it. Archived
|
|
812
|
+
// inbox ids a crashed sweep left without a receipt are healed here too,
|
|
813
|
+
// and so are ids a crashed rewrite left live without one (they were live
|
|
814
|
+
// at read time, i.e. ingested — receipting them is correct).
|
|
815
|
+
if (
|
|
816
|
+
inboxView &&
|
|
817
|
+
(inboxView.visibleIds.size > 0 ||
|
|
818
|
+
inboxView.unreceiptedArchivedIds.size > 0 ||
|
|
819
|
+
inboxView.unreceiptedLiveIds.size > 0)
|
|
820
|
+
) {
|
|
821
|
+
await appendInboxReceipts(filePath, [
|
|
822
|
+
...validated.map((e) => e.id).filter((id) => inboxView.visibleIds.has(id)),
|
|
823
|
+
...inboxView.unreceiptedArchivedIds,
|
|
824
|
+
...inboxView.unreceiptedLiveIds,
|
|
825
|
+
]);
|
|
826
|
+
}
|
|
594
827
|
});
|
|
595
828
|
return validated;
|
|
596
829
|
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The `inbox`-role half of {@link rewriteLearnings} (#1516): the live store is
|
|
833
|
+
* not touched (no backup, no rename). Every validated entry whose id is in
|
|
834
|
+
* neither the live store nor the inbox is appended to the inbox; updates to
|
|
835
|
+
* existing records are dropped (see the BV-004 ceiling at the top of this
|
|
836
|
+
* module) and one stderr notice per call says so.
|
|
837
|
+
*
|
|
838
|
+
* Lock order is store → inbox; `appendLearning` takes only the inbox lock, so
|
|
839
|
+
* the two never wait on each other in reverse.
|
|
840
|
+
*
|
|
841
|
+
* @param {string} filePath — the canonical store
|
|
842
|
+
* @param {object[]} validated
|
|
843
|
+
* @param {string[]} lines — the checked serialized lines, same order
|
|
844
|
+
*/
|
|
845
|
+
async function rewriteAsInbox(filePath, validated, lines) {
|
|
846
|
+
const inboxPath = inboxPathFor(filePath);
|
|
847
|
+
await withLearningsLock(filePath, () =>
|
|
848
|
+
withLearningsLock(inboxPath, async () => {
|
|
849
|
+
const liveRaw = await readRawOrNull(filePath);
|
|
850
|
+
const known = new Set([
|
|
851
|
+
...(liveRaw !== null ? parseLearningsText(liveRaw).entries : [])
|
|
852
|
+
.map((e) => e?.id)
|
|
853
|
+
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
854
|
+
...idsOfJsonl(await readRawOrNull(inboxPath)),
|
|
855
|
+
]);
|
|
856
|
+
const fresh = [];
|
|
857
|
+
validated.forEach((e, i) => {
|
|
858
|
+
if (typeof e?.id !== 'string' || e.id.length === 0 || known.has(e.id)) return;
|
|
859
|
+
known.add(e.id);
|
|
860
|
+
fresh.push(lines[i]);
|
|
861
|
+
});
|
|
862
|
+
if (fresh.length > 0) await appendWithTornTailGuard(inboxPath, fresh.join(''));
|
|
863
|
+
console.warn(
|
|
864
|
+
`[learnings] store-role inbox: rewrite of ${filePath} skipped ` +
|
|
865
|
+
`(${validated.length - fresh.length} existing records unchanged, ` +
|
|
866
|
+
`${fresh.length} new appended to learnings-inbox.jsonl)`
|
|
867
|
+
);
|
|
868
|
+
})
|
|
869
|
+
);
|
|
870
|
+
}
|
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
*/
|
|
127
127
|
|
|
128
128
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
129
|
+
import { readFile } from 'node:fs/promises';
|
|
129
130
|
import { join } from 'node:path';
|
|
130
131
|
|
|
131
132
|
import { affinity } from './affinity.mjs';
|
|
@@ -136,7 +137,13 @@ import {
|
|
|
136
137
|
} from '../reconcile/sanitize.mjs';
|
|
137
138
|
import { learningKeyOf } from './kebab.mjs';
|
|
138
139
|
import { extractProvenance } from '../validate/check-learning-provenance.mjs';
|
|
139
|
-
import {
|
|
140
|
+
import {
|
|
141
|
+
DECAY_DEFAULTS,
|
|
142
|
+
effectiveScore,
|
|
143
|
+
surfaceTopN,
|
|
144
|
+
surfaceLearningsText,
|
|
145
|
+
withVisibleInboxText,
|
|
146
|
+
} from './surface.mjs';
|
|
140
147
|
|
|
141
148
|
// ---------------------------------------------------------------------------
|
|
142
149
|
// Constants — exported so a later wave can wire Session Config keys onto them
|
|
@@ -674,6 +681,8 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
674
681
|
* the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
|
|
675
682
|
* `filePath`: guessing a repo root from a metrics path is the hand-maintained
|
|
676
683
|
* fact this repo keeps getting wrong. Omit it and nothing is filtered.
|
|
684
|
+
* `evidenceCandidates` opts into one explicit candidate JSON artifact, bound
|
|
685
|
+
* to `sourceRepo` and `scope.text`. No artifact discovery or I/O when absent.
|
|
677
686
|
* @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
|
|
678
687
|
*/
|
|
679
688
|
export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
@@ -692,12 +701,52 @@ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
|
692
701
|
? readDeliveredProvenance(o.rulesDir)
|
|
693
702
|
: undefined;
|
|
694
703
|
|
|
695
|
-
const
|
|
704
|
+
const surfaceOpts = {
|
|
696
705
|
now: nowMs,
|
|
697
706
|
confidenceFloor,
|
|
698
707
|
decay: o.decay,
|
|
699
|
-
}
|
|
700
|
-
|
|
708
|
+
};
|
|
709
|
+
let entries;
|
|
710
|
+
let evidence;
|
|
711
|
+
if (o.evidenceCandidates === undefined) {
|
|
712
|
+
entries = await surfaceTopN(filePath, poolSize, surfaceOpts);
|
|
713
|
+
} else {
|
|
714
|
+
let sourceBytes;
|
|
715
|
+
try {
|
|
716
|
+
sourceBytes = await readFile(filePath);
|
|
717
|
+
} catch {
|
|
718
|
+
// Keep the existing empty-baseline behaviour, but do not describe a
|
|
719
|
+
// missing source as a successfully verified empty candidate store.
|
|
720
|
+
}
|
|
721
|
+
// Preserve baseline decoding; candidate additions separately require
|
|
722
|
+
// strict UTF-8 and the exact original bytes from this same read.
|
|
723
|
+
// The inbox records (#1516) join the surfaced text only; `sourceBytes`
|
|
724
|
+
// stays the live store's exact bytes for the candidate check below.
|
|
725
|
+
entries = surfaceLearningsText(
|
|
726
|
+
await withVisibleInboxText(filePath, sourceBytes?.toString('utf8') ?? ''),
|
|
727
|
+
poolSize,
|
|
728
|
+
surfaceOpts,
|
|
729
|
+
);
|
|
730
|
+
try {
|
|
731
|
+
const { readEvidenceCandidates } = await import('./evidence-candidates.mjs');
|
|
732
|
+
const resolved = await readEvidenceCandidates({
|
|
733
|
+
artifactPath: o.evidenceCandidates, sourceRepo: o.sourceRepo,
|
|
734
|
+
query: scope?.text, sourceBytes,
|
|
735
|
+
});
|
|
736
|
+
evidence = resolved.diagnostics;
|
|
737
|
+
const ids = new Set(entries.map((entry) => entry.id));
|
|
738
|
+
for (const entry of resolved.entries) {
|
|
739
|
+
if (!ids.has(entry.id)) {
|
|
740
|
+
entries.push(entry);
|
|
741
|
+
ids.add(entry.id);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} catch {
|
|
745
|
+
evidence = { status: 'error', reason: 'provider_failed', accepted: 0, dropped: 0 };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const selection = selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
|
|
749
|
+
return evidence ? { ...selection, evidence } : selection;
|
|
701
750
|
} catch {
|
|
702
751
|
return emptySelection();
|
|
703
752
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings/store-role.mjs — host-local role of the canonical learnings store (#1516).
|
|
3
|
+
*
|
|
4
|
+
* A vault repo synced between two hosts must not have BOTH hosts rewrite
|
|
5
|
+
* `learnings.jsonl` wholesale (tmp+rename): every rewrite conflicts on merge,
|
|
6
|
+
* and `merge=union` cannot apply to a rewritten file. One host stays the
|
|
7
|
+
* `writer` (default); the other opts into `inbox` and only ever APPENDS to the
|
|
8
|
+
* sibling `learnings-inbox.jsonl`, which the writer ingests.
|
|
9
|
+
*
|
|
10
|
+
* Fail-closed: ONLY "nothing configured" means writer. A role that is set but
|
|
11
|
+
* unusable — an invalid `SO_LEARNINGS_STORE_ROLE`, an owner.yaml that exists
|
|
12
|
+
* but cannot be read, or one that names a top-level `learnings` key whose
|
|
13
|
+
* `store-role` did not survive loading (typo in the value or the key,
|
|
14
|
+
* unparseable file, missing YAML parser) — resolves to `invalid`, and every WRITE path for the canonical
|
|
15
|
+
* store refuses via {@link assertStoreRoleUsable}. Readers keep working.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately import-light: the only repo import is `../owner-yaml.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { loadOwnerConfig, resolveOwnerYamlPath } from '../owner-yaml.mjs';
|
|
23
|
+
|
|
24
|
+
/** Valid store roles. `writer` is the default when nothing is configured. */
|
|
25
|
+
export const STORE_ROLES = Object.freeze(['writer', 'inbox']);
|
|
26
|
+
|
|
27
|
+
const ENV_KEY = 'SO_LEARNINGS_STORE_ROLE';
|
|
28
|
+
const CANONICAL_STORE = 'learnings.jsonl';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A top-level `learnings` key in the RAW owner.yaml text (block style, plain
|
|
32
|
+
* or quoted key). Ceiling (BV-004): a flow-style top-level mapping
|
|
33
|
+
* (`{learnings: {...}}`) is not detected, so a broken one of those still
|
|
34
|
+
* resolves to writer. Revisit-trigger: an owner.yaml written in flow style.
|
|
35
|
+
*/
|
|
36
|
+
const LEARNINGS_KEY_RE = /^["']?learnings["']?\s*:/m;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {{ role: 'writer'|'inbox', source: 'env'|'owner-yaml'|'default' }
|
|
40
|
+
* | { role: 'invalid', source: 'env'|'owner-yaml', reason: string }} StoreRoleResult
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** @type {Map<string, StoreRoleResult>} */
|
|
44
|
+
const cache = new Map();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown by {@link assertStoreRoleUsable} when a write to the canonical store
|
|
48
|
+
* is attempted while this host's store role is invalid. Nothing was written.
|
|
49
|
+
*/
|
|
50
|
+
export class LearningsStoreRoleError extends Error {
|
|
51
|
+
/**
|
|
52
|
+
* @param {'env'|'owner-yaml'} source
|
|
53
|
+
* @param {string} reason
|
|
54
|
+
*/
|
|
55
|
+
constructor(source, reason) {
|
|
56
|
+
super(
|
|
57
|
+
`learnings store role is invalid (${source}: ${reason}) — nothing written; ` +
|
|
58
|
+
`fix owner.yaml learnings.store-role or ${ENV_KEY}`,
|
|
59
|
+
);
|
|
60
|
+
this.name = 'LearningsStoreRoleError';
|
|
61
|
+
this.source = source;
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The owner.yaml half of {@link resolveStoreRole}.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} ownerPath
|
|
70
|
+
* @param {(opts: {path: string}) => any} loadOwner
|
|
71
|
+
* @returns {StoreRoleResult}
|
|
72
|
+
*/
|
|
73
|
+
function resolveFromOwnerYaml(ownerPath, loadOwner) {
|
|
74
|
+
const loaded = loadOwner({ path: ownerPath });
|
|
75
|
+
const section = loaded?.config?.learnings;
|
|
76
|
+
const fromOwner = section?.['store-role'];
|
|
77
|
+
if (STORE_ROLES.includes(fromOwner)) return { role: fromOwner, source: 'owner-yaml' };
|
|
78
|
+
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = readFileSync(ownerPath, 'utf8');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (/** @type {NodeJS.ErrnoException} */ (err)?.code === 'ENOENT') return { role: 'writer', source: 'default' };
|
|
84
|
+
return { role: 'invalid', source: 'owner-yaml', reason: `owner.yaml unreadable (${err?.code ?? 'error'})` };
|
|
85
|
+
}
|
|
86
|
+
if (!LEARNINGS_KEY_RE.test(raw)) return { role: 'writer', source: 'default' };
|
|
87
|
+
// A learnings section that loaded as an object without a store-role, or as
|
|
88
|
+
// an empty `learnings:` (null), is "key absent" — writer (#1686). A section
|
|
89
|
+
// with any other key was dropped by owner-yaml validation, so it is
|
|
90
|
+
// undefined here. Anything else means the configured value was lost.
|
|
91
|
+
if (section === null && loaded?.config && 'learnings' in loaded.config) return { role: 'writer', source: 'default' };
|
|
92
|
+
const isObject = section !== null && typeof section === 'object' && !Array.isArray(section);
|
|
93
|
+
if (isObject && (fromOwner === undefined || fromOwner === null)) return { role: 'writer', source: 'default' };
|
|
94
|
+
const dropped = Array.isArray(loaded?.droppedSections)
|
|
95
|
+
? loaded.droppedSections.find((d) => d?.section === 'learnings')
|
|
96
|
+
: undefined;
|
|
97
|
+
const reason =
|
|
98
|
+
dropped?.errors?.[0] ??
|
|
99
|
+
(loaded?.reason ? `owner.yaml ${loaded.reason}` : null) ??
|
|
100
|
+
(fromOwner !== undefined ? `store-role ${JSON.stringify(fromOwner)} not one of ${STORE_ROLES.join(', ')}` : null) ??
|
|
101
|
+
'learnings section present but unusable';
|
|
102
|
+
return { role: 'invalid', source: 'owner-yaml', reason };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve this host's learnings-store role.
|
|
107
|
+
*
|
|
108
|
+
* Precedence: env `SO_LEARNINGS_STORE_ROLE` (trimmed) > owner.yaml
|
|
109
|
+
* `learnings.store-role` > `writer`. A non-empty env value that is not a role
|
|
110
|
+
* resolves to `invalid` (no fallback to owner.yaml); so does an owner.yaml
|
|
111
|
+
* that exists but is unreadable, or that names a top-level `learnings` key
|
|
112
|
+
* whose `store-role` did not load as a valid role. Only an absent file, or one
|
|
113
|
+
* without a `learnings` key / `store-role` (an empty `learnings:` included),
|
|
114
|
+
* means `writer`. Memoized per
|
|
115
|
+
* process on (env value, owner.yaml path). Never throws: an unexpected error
|
|
116
|
+
* resolves to `invalid` (fail-closed), not to writer.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ env?: NodeJS.ProcessEnv | Record<string, string|undefined>,
|
|
119
|
+
* loadOwner?: (opts: {path: string}) => {config?: any} }} [opts] — `loadOwner`
|
|
120
|
+
* defaults to `loadOwnerConfig` (test seam)
|
|
121
|
+
* @returns {StoreRoleResult} `role: 'writer'|'inbox'|'invalid'`; `reason` only on invalid
|
|
122
|
+
*/
|
|
123
|
+
export function resolveStoreRole({ env = process.env, loadOwner } = {}) {
|
|
124
|
+
let source = /** @type {'env'|'owner-yaml'} */ ('owner-yaml');
|
|
125
|
+
try {
|
|
126
|
+
const envValue = (env[ENV_KEY] || '').trim();
|
|
127
|
+
const ownerPath = resolveOwnerYamlPath(env);
|
|
128
|
+
const key = `${envValue}\u0000${ownerPath}`;
|
|
129
|
+
const hit = cache.get(key);
|
|
130
|
+
if (hit) return hit;
|
|
131
|
+
|
|
132
|
+
/** @type {StoreRoleResult} */
|
|
133
|
+
let result;
|
|
134
|
+
if (envValue !== '') {
|
|
135
|
+
source = 'env';
|
|
136
|
+
result = STORE_ROLES.includes(envValue)
|
|
137
|
+
? { role: /** @type {'writer'|'inbox'} */ (envValue), source: 'env' }
|
|
138
|
+
: {
|
|
139
|
+
role: 'invalid',
|
|
140
|
+
source: 'env',
|
|
141
|
+
reason: `${ENV_KEY}=${JSON.stringify(envValue)} not one of ${STORE_ROLES.join(', ')}`,
|
|
142
|
+
};
|
|
143
|
+
} else {
|
|
144
|
+
result = resolveFromOwnerYaml(ownerPath, loadOwner ?? loadOwnerConfig);
|
|
145
|
+
}
|
|
146
|
+
cache.set(key, result);
|
|
147
|
+
return result;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { role: 'invalid', source, reason: `store role not resolvable (${err?.message ?? String(err)})` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Refuse a write to the canonical store while this host's role is invalid.
|
|
155
|
+
* Call it on every WRITE path before any lock or disk write; other files and
|
|
156
|
+
* readers are unaffected.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} filePath
|
|
159
|
+
* @throws {LearningsStoreRoleError} iff `filePath` is the canonical store and the role is invalid
|
|
160
|
+
*/
|
|
161
|
+
export function assertStoreRoleUsable(filePath) {
|
|
162
|
+
if (!isCanonicalStore(filePath)) return;
|
|
163
|
+
const resolved = resolveStoreRole();
|
|
164
|
+
if (resolved.role === 'invalid') throw new LearningsStoreRoleError(resolved.source, resolved.reason);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Clear the memo. Tests only. */
|
|
168
|
+
export function _resetStoreRoleCacheForTests() {
|
|
169
|
+
cache.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True iff `filePath` names the canonical store (`learnings.jsonl`). Only that
|
|
174
|
+
* file gets the inbox behaviour; any other path keeps the plain store semantics.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} filePath
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function isCanonicalStore(filePath) {
|
|
180
|
+
return typeof filePath === 'string' && path.basename(filePath) === CANONICAL_STORE;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sibling inbox file the `inbox` role appends to.
|
|
185
|
+
* @param {string} storePath
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function inboxPathFor(storePath) {
|
|
189
|
+
return path.join(path.dirname(storePath), 'learnings-inbox.jsonl');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sibling receipts file the writer appends `{id, ingested_at}` lines to once
|
|
194
|
+
* an inbox record has been ingested into the live store.
|
|
195
|
+
* @param {string} storePath
|
|
196
|
+
* @returns {string}
|
|
197
|
+
*/
|
|
198
|
+
export function receiptsPathFor(storePath) {
|
|
199
|
+
return path.join(path.dirname(storePath), 'learnings-inbox-ingested.jsonl');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Sibling archive file (expired learnings).
|
|
204
|
+
* @param {string} storePath
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
export function archivePathFor(storePath) {
|
|
208
|
+
return path.join(path.dirname(storePath), 'learnings-archive.jsonl');
|
|
209
|
+
}
|