@event4u/agent-config 14.12.0 → 14.14.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +118 -155
- package/CONTRIBUTING.md +38 -0
- package/CREDITS.md +135 -0
- package/NOTICE +13 -0
- package/README.md +8 -6
- package/dist/agent-src/commands/analyze/inbox.md +145 -2
- package/dist/agent-src/commands/feature/dev.md +7 -2
- package/dist/agent-src/commands/ghostwriter/write.md +5 -1
- package/dist/agent-src/commands/implement-ticket.md +1 -1
- package/dist/agent-src/commands/mission/upgrade.md +1 -1
- package/dist/agent-src/commands/orchestrate.md +2 -2
- package/dist/agent-src/commands/post-as/me.md +5 -1
- package/dist/agent-src/commands/pr/merge.md +19 -6
- package/dist/agent-src/commands/roadmap/process-full.md +19 -5
- package/dist/agent-src/commands/tdd/red.md +12 -2
- package/dist/agent-src/commands/work.md +1 -1
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +40 -0
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -2
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +21 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +1 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +40 -0
- package/dist/agent-src/contexts/execution/subagent-steering.md +13 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +96 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +1 -1
- package/dist/agent-src/rules/broken-access-control.md +4 -0
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/rules/code-provenance.md +4 -0
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/domain-safety-pii.md +4 -0
- package/dist/agent-src/rules/domain-safety-retention.md +4 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +14 -12
- package/dist/agent-src/rules/improve-before-implement.md +6 -8
- package/dist/agent-src/rules/laravel-routing.md +1 -1
- package/dist/agent-src/rules/lethal-trifecta-guard.md +4 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +12 -34
- package/dist/agent-src/rules/symfony-routing.md +1 -1
- package/dist/agent-src/rules/token-efficiency.md +14 -0
- package/dist/agent-src/rules/tool-safety.md +4 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +4 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +252 -8
- package/dist/agent-src/scripts/guarded_baseline.ts +343 -0
- package/dist/agent-src/scripts/roadmap_set_step.ts +253 -0
- package/dist/agent-src/scripts/stub_queue.ts +82 -0
- package/dist/agent-src/scripts/stubs_due.ts +516 -0
- package/dist/agent-src/scripts/ticket_bundles.ts +84 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +16 -12
- package/dist/agent-src/skills/brand-asset-generation/SKILL.md +17 -4
- package/dist/agent-src/skills/brand-audit/SKILL.md +33 -0
- package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -0
- package/dist/agent-src/skills/database/SKILL.md +8 -0
- package/dist/agent-src/skills/dependency-upgrade/SKILL.md +27 -0
- package/dist/agent-src/skills/design-review/SKILL.md +1 -1
- package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +17 -1
- package/dist/agent-src/skills/feature-planning/SKILL.md +9 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +44 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +1 -1
- package/dist/agent-src/skills/js-library-packaging/scripts/check_package_surface.ts +38 -4
- package/dist/agent-src/skills/judge-artifact-completeness/rubrics/ticket-quality-score.json +2 -2
- package/dist/agent-src/skills/laravel/SKILL.md +35 -0
- package/dist/agent-src/skills/laravel-migration/SKILL.md +57 -3
- package/dist/agent-src/skills/monorepo-workspace/SKILL.md +26 -0
- package/dist/agent-src/skills/playbook-authoring/SKILL.md +28 -1
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/references/procedure.md +5 -5
- package/dist/agent-src/skills/sql-writing/SKILL.md +7 -1
- package/dist/agent-src/skills/standards-from-config/SKILL.md +27 -4
- package/dist/agent-src/skills/supply-chain-intake/SKILL.md +11 -4
- package/dist/agent-src/skills/symfony-workflow/SKILL.md +35 -0
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +85 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +33 -9
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +1 -1
- package/dist/agent-src/skills/testing-anti-patterns/process-anti-patterns.md +37 -0
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +45 -2
- package/dist/agent-src/skills/upstream-contribute/SKILL.md +38 -1
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +9 -4
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +97 -3
- package/dist/agent-src/templates/scripts/check_memory.ts +258 -17
- package/dist/agent-src/templates/scripts/check_memory_proposal.ts +9 -3
- package/dist/agent-src/templates/scripts/memory_lookup.ts +86 -0
- package/dist/agent-src/templates/scripts/memory_signal.ts +44 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +90 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/index.ts +11 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +23 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +10 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +5 -1
- package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +102 -14
- package/dist/cli/commands/uiAudit.js +124 -5
- package/dist/cli/registry.js +2 -0
- package/dist/cli-delegate/{chunk-HYWSRNUX.js → chunk-2DK2VJWG.js} +3 -3
- package/dist/cli-delegate/{chunk-JCW7ZLHK.js → chunk-4FS4WRXP.js} +5 -0
- package/dist/cli-delegate/{chunk-OMIVVCG2.js → chunk-A4QJ6ENT.js} +24 -0
- package/dist/cli-delegate/{chunk-JJA2QEWA.js → chunk-AKJGUQNO.js} +466 -285
- package/dist/cli-delegate/{chunk-E46KPN6K.js → chunk-BTWUYDGN.js} +424 -363
- package/dist/cli-delegate/{chunk-K4FFODTS.js → chunk-ILMUPLGW.js} +1227 -316
- package/dist/cli-delegate/{chunk-FF7WAFMR.js → chunk-KPBWFOPZ.js} +5 -1
- package/dist/cli-delegate/cmd_adr_effective.js +102 -0
- package/dist/cli-delegate/cmd_conformance.js +80 -11
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +3 -2
- package/dist/cli-delegate/cmd_fleet.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +3 -2
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +3 -2
- package/dist/cli-delegate/cmd_uninstall.js +3 -2
- package/dist/cli-delegate/cmd_validate.js +3 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +58 -58
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +2900 -1049
- package/dist/install/claudeRuleRewrite.js +254 -0
- package/dist/install/detect.js +29 -16
- package/dist/install/detect_php_shape.js +159 -0
- package/dist/install/install.mjs +634 -428
- package/dist/mcp/awesome-mcp-servers.row.md +1 -1
- package/dist/mcp/mcp-cloudflare-catalogue.json +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +3 -3
- package/dist/mcp/server.mjs +4895 -2627
- package/dist/server/schemas/settings.js +3 -0
- package/dist/ui/assets/index-D0jaCRPM.js +29 -0
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +1 -1
- package/docs/THIRD-PARTY-NOTICES.md +25 -0
- package/docs/contracts/provider-lifecycle.md +1 -1
- package/docs/contracts/settings-classes.md +10 -4
- package/docs/guidelines/agent-infra/5w2h-analysis.md +3 -3
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +58 -3
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +2 -2
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +3 -3
- package/docs/guidelines/agent-infra/break-glass-usage.md +7 -7
- package/docs/guidelines/agent-infra/carve-out-predicates.md +3 -3
- package/docs/guidelines/agent-infra/comparison-matrix.md +1 -1
- package/docs/guidelines/agent-infra/critical-thinking.md +4 -4
- package/docs/guidelines/agent-infra/direct-answers-demos.md +2 -2
- package/docs/guidelines/agent-infra/domain-eval-anti-pattern.md +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +3 -3
- package/docs/guidelines/agent-infra/existence-question-verdicts.md +80 -0
- package/docs/guidelines/agent-infra/first-principles.md +2 -2
- package/docs/guidelines/agent-infra/framework-neutrality-patterns.md +2 -2
- package/docs/guidelines/agent-infra/guarded-baseline.md +39 -0
- package/docs/guidelines/agent-infra/inversion-thinking.md +5 -5
- package/docs/guidelines/agent-infra/language-and-tone-examples.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +10 -10
- package/docs/guidelines/agent-infra/memory-access.md +1 -1
- package/docs/guidelines/agent-infra/mental-models.md +3 -3
- package/docs/guidelines/agent-infra/reuse-verdict-mechanics.md +71 -0
- package/docs/guidelines/agent-infra/review-routing-data-format.md +7 -7
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +13 -7
- package/docs/guidelines/agent-infra/role-contracts.md +2 -2
- package/docs/guidelines/agent-infra/role-mode-router.md +3 -3
- package/docs/guidelines/agent-infra/rule-type-governance.md +2 -2
- package/docs/guidelines/agent-infra/scqa-framework.md +5 -5
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +6 -6
- package/docs/guidelines/agent-infra/simplicity-and-goal-demos.md +2 -2
- package/docs/guidelines/agent-infra/six-hats.md +3 -3
- package/docs/guidelines/agent-infra/skill-quality-checklist.md +6 -6
- package/docs/guidelines/agent-infra/source-confidentiality-mechanics.md +109 -0
- package/docs/guidelines/agent-infra/systems-thinking.md +1 -1
- package/docs/guidelines/agent-infra/verify-before-complete-demos.md +2 -2
- package/docs/guidelines/augment-portability-patterns.md +6 -4
- package/docs/guidelines/code-clarity.md +18 -0
- package/docs/guidelines/component-oriented-and-oop-development.md +3 -2
- package/docs/guidelines/cross-role-handoff.md +2 -2
- package/docs/guidelines/design-fidelity-mechanics.md +1 -1
- package/docs/guidelines/docs/readme-size-and-splitting.md +3 -3
- package/docs/guidelines/php/database.md +90 -7
- package/docs/guidelines/prompt-templates.md +7 -7
- package/llms.txt +2 -2
- package/package.json +5 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +252 -8
- package/src/agent-src/scripts/guarded_baseline.ts +343 -0
- package/src/agent-src/scripts/roadmap_set_step.ts +253 -0
- package/src/agent-src/scripts/stub_queue.ts +82 -0
- package/src/agent-src/scripts/stubs_due.ts +516 -0
- package/src/agent-src/scripts/ticket_bundles.ts +84 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +16 -12
- package/src/agent-src/templates/scripts/check_memory.ts +258 -17
- package/src/agent-src/templates/scripts/check_memory_proposal.ts +9 -3
- package/src/agent-src/templates/scripts/memory_lookup.ts +86 -0
- package/src/agent-src/templates/scripts/memory_signal.ts +44 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +90 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +11 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +23 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +10 -1
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +5 -1
- package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +102 -14
- package/src/config/agent-settings.template.yml +32 -3
- package/src/config/assurance-capability-registry.json +21 -9
- package/src/config/canonical-terms.yml +119 -0
- package/src/config/capability-native-outcome-bars.json +81 -0
- package/src/config/ci-local-parity.yml +80 -0
- package/src/config/drafting-channels.yml +43 -0
- package/src/config/estate-count-budget.json +57 -36
- package/src/config/evaluator-budgets.json +14 -14
- package/src/config/gate-coverage.yml +729 -63
- package/src/config/gate-reachability-exemptions.json +21 -0
- package/src/config/gate-violation-baselines.json +42 -19
- package/src/config/harness-evolution-budget.json +25 -0
- package/src/config/lapsed-beta-baseline.json +96 -0
- package/src/config/memory-twin-verdicts.yml +129 -0
- package/src/config/metric-registry.yml +124 -0
- package/src/config/pack-size-budget.json +16 -15
- package/src/config/preamble-payload-budget.json +12 -4
- package/src/config/profiles/balanced.ini +27 -0
- package/src/config/profiles/full.ini +27 -0
- package/src/config/profiles/minimal.ini +27 -0
- package/src/config/publish-surface.json +9 -2
- package/src/config/routing-coverage-seed.json +16 -2
- package/src/config/rule-stub-ceilings.json +133 -112
- package/src/config/turnaround-budget.json +66 -0
- package/src/install/claudeRuleRewrite.ts +288 -0
- package/src/install/detect.ts +60 -17
- package/src/install/detect_php_shape.ts +191 -0
- package/src/scripts/_cli/cmd_doctor.ts +27 -112
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/activation_ladder.ts +255 -0
- package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
- package/src/scripts/_lib/activation_states.ts +88 -0
- package/src/scripts/_lib/adherence_detectors.ts +143 -0
- package/src/scripts/_lib/adr_frontmatter.ts +26 -0
- package/src/scripts/_lib/arith_claims.ts +181 -0
- package/src/scripts/_lib/arm_ranking.ts +62 -0
- package/src/scripts/_lib/asset_delivery_ledger.ts +303 -0
- package/src/scripts/_lib/audit_field_provenance.ts +54 -0
- package/src/scripts/_lib/auto_dispatch.ts +32 -3
- package/src/scripts/_lib/bench_ab_size_claim.ts +98 -4
- package/src/scripts/_lib/benchmark_cache_fields.ts +158 -0
- package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
- package/src/scripts/_lib/candidate_proposer.ts +402 -0
- package/src/scripts/_lib/candidate_record.ts +601 -0
- package/src/scripts/_lib/capture_rate.ts +319 -0
- package/src/scripts/_lib/carrier_status.ts +118 -0
- package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
- package/src/scripts/_lib/collector_denominator.ts +771 -0
- package/src/scripts/_lib/collector_record.ts +340 -0
- package/src/scripts/_lib/collector_store.ts +664 -0
- package/src/scripts/_lib/collector_supervision.ts +827 -0
- package/src/scripts/_lib/concern_estate.ts +77 -0
- package/src/scripts/_lib/config_chain.ts +477 -0
- package/src/scripts/_lib/config_cost.ts +209 -0
- package/src/scripts/_lib/corpus_manifest.ts +726 -0
- package/src/scripts/_lib/council_settings_block.ts +6 -0
- package/src/scripts/_lib/curator_ops.ts +172 -0
- package/src/scripts/_lib/delivery_arm_experiment.ts +282 -0
- package/src/scripts/_lib/delivery_criticality.ts +126 -0
- package/src/scripts/_lib/doctor_runtime_checks.ts +70 -0
- package/src/scripts/_lib/empty_cycles.ts +88 -0
- package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
- package/src/scripts/_lib/eval_publication.ts +354 -0
- package/src/scripts/_lib/evaluation_cascade.ts +452 -0
- package/src/scripts/_lib/evaluation_vector.ts +283 -0
- package/src/scripts/_lib/evaluator_promotion.ts +225 -0
- package/src/scripts/_lib/evidence_basis.ts +118 -0
- package/src/scripts/_lib/evolution_roi.ts +562 -0
- package/src/scripts/_lib/exec_evidence.ts +8 -0
- package/src/scripts/_lib/experience_card.ts +272 -0
- package/src/scripts/_lib/experience_report.ts +151 -0
- package/src/scripts/_lib/experiment_freeze.ts +112 -0
- package/src/scripts/_lib/generated_by.ts +66 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +239 -0
- package/src/scripts/_lib/hook_effect_probe.ts +141 -0
- package/src/scripts/_lib/host_denominator.ts +687 -0
- package/src/scripts/_lib/host_projection_reach.ts +157 -0
- package/src/scripts/_lib/ignored_blocker.ts +148 -0
- package/src/scripts/_lib/install_reach_checks.ts +495 -0
- package/src/scripts/_lib/json_duplicate_keys.ts +150 -0
- package/src/scripts/_lib/judge_hygiene.ts +228 -0
- package/src/scripts/_lib/legacy_boundary_map.ts +228 -0
- package/src/scripts/_lib/lexical_shortlist.ts +215 -0
- package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
- package/src/scripts/_lib/loop_guards.ts +299 -0
- package/src/scripts/_lib/loss_class.ts +129 -0
- package/src/scripts/_lib/machine_wake.ts +51 -0
- package/src/scripts/_lib/md_prose_lines.ts +177 -0
- package/src/scripts/_lib/minimality_tiebreak.ts +168 -0
- package/src/scripts/_lib/one_resolver_invariant.ts +449 -0
- package/src/scripts/_lib/orchestration_record.ts +253 -5
- package/src/scripts/_lib/outcome_envelope.ts +227 -0
- package/src/scripts/_lib/outcome_vocabularies.ts +125 -0
- package/src/scripts/_lib/paired_stats.ts +245 -0
- package/src/scripts/_lib/paired_verdict.ts +226 -0
- package/src/scripts/_lib/pathology_archive.ts +393 -0
- package/src/scripts/_lib/prefix_stable_surfaces.ts +123 -0
- package/src/scripts/_lib/privacy_class.ts +47 -0
- package/src/scripts/_lib/promotion_capability.ts +315 -0
- package/src/scripts/_lib/promotion_evidence.ts +414 -0
- package/src/scripts/_lib/promotion_review.ts +294 -0
- package/src/scripts/_lib/prompt_shape.ts +92 -0
- package/src/scripts/_lib/regression_neighbourhood.ts +259 -0
- package/src/scripts/_lib/release_highlights.ts +70 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/repeated_failure.ts +105 -0
- package/src/scripts/_lib/repo_root.ts +111 -0
- package/src/scripts/_lib/review_skipped_record.ts +37 -0
- package/src/scripts/_lib/role_split.ts +338 -0
- package/src/scripts/_lib/routing_corpus.ts +307 -0
- package/src/scripts/_lib/routing_index_input.ts +104 -0
- package/src/scripts/_lib/rule_injection.ts +22 -1
- package/src/scripts/_lib/runtime_journal.ts +1499 -0
- package/src/scripts/_lib/runtime_wiring_checks.ts +430 -0
- package/src/scripts/_lib/semantic_noop.ts +167 -0
- package/src/scripts/_lib/skill_catalogue.ts +11 -1
- package/src/scripts/_lib/source_digest.ts +200 -0
- package/src/scripts/_lib/source_redact.ts +260 -0
- package/src/scripts/_lib/source_shape.ts +502 -0
- package/src/scripts/_lib/source_snapshot_dedup.ts +199 -0
- package/src/scripts/_lib/strip_comments.d.mts +8 -0
- package/src/scripts/_lib/strip_comments.mjs +65 -0
- package/src/scripts/_lib/test_red_state.ts +154 -0
- package/src/scripts/_lib/trigger_shift.ts +144 -0
- package/src/scripts/_lib/untrusted_content.ts +82 -7
- package/src/scripts/activation_receipt.ts +258 -0
- package/src/scripts/adoption_report.ts +1 -1
- package/src/scripts/adr/evidence_census.ts +1 -1
- package/src/scripts/adr/regenerate_index.ts +26 -2
- package/src/scripts/adr_cite_check.ts +164 -1
- package/src/scripts/ai_council/blind_review.ts +11 -1
- package/src/scripts/ai_council/clients.ts +4 -0
- package/src/scripts/ai_council/config.ts +3 -0
- package/src/scripts/ai_council/consensus.ts +28 -8
- package/src/scripts/ai_council/consensus_round.ts +226 -0
- package/src/scripts/ai_council/cross_exam.ts +128 -0
- package/src/scripts/ai_council/disagreement_signal.ts +338 -0
- package/src/scripts/ai_council/explain_route.ts +157 -0
- package/src/scripts/ai_council/information_gain.ts +293 -0
- package/src/scripts/ai_council/inline_findings.ts +340 -0
- package/src/scripts/ai_council/judge_position_bias.ts +197 -0
- package/src/scripts/ai_council/leakage_corpus.ts +385 -0
- package/src/scripts/ai_council/leakage_patterns.ts +453 -0
- package/src/scripts/ai_council/minority_retention.ts +171 -0
- package/src/scripts/ai_council/orchestrator.ts +16 -203
- package/src/scripts/ai_council/orchestrator_results.ts +23 -3
- package/src/scripts/ai_council/pricing.ts +106 -0
- package/src/scripts/ai_council/prompts.ts +74 -7
- package/src/scripts/ai_council/provider_leakage_bench.ts +327 -0
- package/src/scripts/ai_council/py_format.ts +14 -3
- package/src/scripts/ai_council/recouncil_guard.ts +293 -0
- package/src/scripts/ai_council/recouncil_savings.ts +299 -0
- package/src/scripts/ai_council/replay_route.ts +223 -0
- package/src/scripts/ai_council/response_record.ts +50 -0
- package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
- package/src/scripts/ai_council/routing_training_row.ts +197 -0
- package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
- package/src/scripts/ai_council/topology_bench_manifest.ts +860 -0
- package/src/scripts/ai_council/topology_vocabulary.ts +175 -0
- package/src/scripts/asset_delivery_ledger.ts +157 -0
- package/src/scripts/attest_artifact.ts +193 -0
- package/src/scripts/audit_auto_rules.ts +2 -1
- package/src/scripts/audit_command_surface.ts +1 -1
- package/src/scripts/audit_mcp_tools.ts +2 -2
- package/src/scripts/audit_skill_overlap_allowlist.json +6 -1
- package/src/scripts/audit_user_type_axis.ts +2 -1
- package/src/scripts/bench_ab_clone.ts +215 -10
- package/src/scripts/bench_ab_integrity.ts +119 -5
- package/src/scripts/bench_ab_v2_stats.ts +40 -213
- package/src/scripts/build_archive_index.ts +1 -1
- package/src/scripts/build_linear_digest.ts +1 -1
- package/src/scripts/build_proof.ts +11 -4
- package/src/scripts/build_rule_trigger_matrix.ts +1 -1
- package/src/scripts/build_source_digests.ts +157 -0
- package/src/scripts/cache_realization_report.ts +103 -0
- package/src/scripts/check_adr_frontmatter.ts +131 -10
- package/src/scripts/check_benchmark_report_fields.ts +207 -0
- package/src/scripts/check_beta_review_markers.ts +235 -6
- package/src/scripts/check_bundle_path_leakage.ts +34 -1
- package/src/scripts/check_claims.ts +576 -6
- package/src/scripts/check_composite_arming.ts +35 -0
- package/src/scripts/check_concern_admissions.ts +365 -0
- package/src/scripts/check_enforcement_coverage.ts +28 -0
- package/src/scripts/check_estate_count.ts +347 -25
- package/src/scripts/check_gate_reachability.ts +383 -0
- package/src/scripts/check_host_loadability.ts +104 -0
- package/src/scripts/check_loss_class_declared.ts +308 -0
- package/src/scripts/check_md_language.ts +15 -98
- package/src/scripts/check_memory_proposal.ts +18 -11
- package/src/scripts/check_no_currency_in_cost_surfaces.ts +225 -0
- package/src/scripts/check_no_external_sources.ts +281 -6
- package/src/scripts/check_pack_conformance_fixture.ts +11 -1
- package/src/scripts/check_pack_size.ts +59 -2
- package/src/scripts/check_preamble_payload_budget.ts +114 -3
- package/src/scripts/check_prefix_stable_mutation.ts +372 -0
- package/src/scripts/check_publish_surface.ts +10 -1
- package/src/scripts/check_references.ts +24 -1
- package/src/scripts/check_release_highlights.ts +96 -13
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/check_rule_evidence_declaration.ts +316 -0
- package/src/scripts/check_rule_projection_integrity.ts +14 -4
- package/src/scripts/check_score_contract.ts +26 -3
- package/src/scripts/check_static_parity.ts +285 -0
- package/src/scripts/check_supervision_claim_atomicity.ts +498 -0
- package/src/scripts/check_suppression_hygiene.ts +15 -0
- package/src/scripts/ci_settle.ts +39 -2
- package/src/scripts/code_graph/build.ts +60 -3
- package/src/scripts/code_graph/extract.ts +44 -0
- package/src/scripts/code_graph/types.ts +24 -0
- package/src/scripts/collector_daemon.ts +714 -0
- package/src/scripts/config_cost_report.ts +302 -0
- package/src/scripts/consumer_matrix.ts +74 -1
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/cost/budget.mjs +10 -3
- package/src/scripts/cost/track.mjs +10 -3
- package/src/scripts/council_cli.ts +37 -54
- package/src/scripts/dispatch_r2_reviewer.ts +46 -5
- package/src/scripts/evolution_lab.ts +1153 -0
- package/src/scripts/external_sources_denylist.json +5 -18
- package/src/scripts/extract_audit_patterns.ts +21 -0
- package/src/scripts/fold_intake.ts +12 -1
- package/src/scripts/gen_discovery_baseline.ts +1 -1
- package/src/scripts/generate_capabilities_index.ts +12 -5
- package/src/scripts/generate_capability_matrix.ts +3 -3
- package/src/scripts/generate_command_flows.ts +2 -2
- package/src/scripts/generate_cookbook.ts +2 -2
- package/src/scripts/generate_index.ts +8 -1
- package/src/scripts/generate_knowledge_index.ts +2 -1
- package/src/scripts/generate_ownership_matrix.ts +2 -1
- package/src/scripts/generate_pack_manifests.ts +10 -2
- package/src/scripts/generate_role_experiences_catalog.ts +2 -2
- package/src/scripts/generate_settings_reference.ts +1 -1
- package/src/scripts/git_authorization_hook.ts +22 -1
- package/src/scripts/grade_target_readiness.ts +48 -5
- package/src/scripts/hook_effect_doctor.ts +261 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +125 -16
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +271 -0
- package/src/scripts/hooks/block_unauthorized_git.ts +18 -0
- package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +32 -0
- package/src/scripts/hooks/journal_record_hook.ts +345 -0
- package/src/scripts/hooks/prepush_metadata_sources.sh +110 -0
- package/src/scripts/hooks/run_continuation_hook.ts +44 -9
- package/src/scripts/hooks/shims/install.sh +61 -0
- package/src/scripts/hooks/shims/php +101 -0
- package/src/scripts/hooks/suggestion_capture_hook.ts +10 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/hot_context_hook.ts +16 -0
- package/src/scripts/install-hooks.sh +20 -5
- package/src/scripts/install.ts +17 -35
- package/src/scripts/inventory_frontmatter.ts +2 -1
- package/src/scripts/language_mirror_hook.ts +14 -2
- package/src/scripts/lint_adr_sweep_routing.ts +12 -0
- package/src/scripts/lint_canonical_terms.ts +604 -0
- package/src/scripts/lint_carrier_integrity.ts +820 -0
- package/src/scripts/lint_code_comments.ts +643 -0
- package/src/scripts/lint_composition_review.ts +619 -0
- package/src/scripts/lint_consolidation_lineage.ts +666 -0
- package/src/scripts/lint_eval_specs.ts +593 -0
- package/src/scripts/lint_hook_manifest.ts +19 -0
- package/src/scripts/lint_host_portability.ts +373 -0
- package/src/scripts/lint_judge_prompt_expectation.ts +27 -1
- package/src/scripts/lint_memory_twin_parity.ts +335 -0
- package/src/scripts/lint_metric_consumers.ts +355 -0
- package/src/scripts/lint_plan_risk_register.ts +39 -19
- package/src/scripts/lint_promotion_paths.ts +779 -0
- package/src/scripts/lint_skill_descriptions.ts +18 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +58 -0
- package/src/scripts/lint_skill_link_reach.ts +252 -0
- package/src/scripts/lint_skill_trigger_corpus.ts +426 -23
- package/src/scripts/lint_ticket_buildable.ts +17 -1
- package/src/scripts/llm_propose.ts +256 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +1 -1
- package/src/scripts/mcp_server/tool_catalog_source.ts +1 -1
- package/src/scripts/measure_delivery_sets.ts +361 -0
- package/src/scripts/measure_docs_dead_links.ts +348 -0
- package/src/scripts/measure_host_capture.ts +461 -0
- package/src/scripts/measure_routing_signal.ts +307 -0
- package/src/scripts/migration_status.ts +3 -3
- package/src/scripts/model_rule_injection.ts +76 -0
- package/src/scripts/orchestration_record.ts +14 -0
- package/src/scripts/preamble_byte_census.ts +139 -10
- package/src/scripts/prepack-check.mjs +4 -1
- package/src/scripts/probe_council_retention.ts +495 -0
- package/src/scripts/probe_turnaround.ts +384 -0
- package/src/scripts/probe_unblocked_ask.ts +279 -0
- package/src/scripts/release.ts +21 -37
- package/src/scripts/release_drill.ts +143 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +84 -0
- package/src/scripts/render_benchmark_md.ts +1 -1
- package/src/scripts/rule_activation_census.ts +70 -9
- package/src/scripts/rule_backlinks.ts +1 -1
- package/src/scripts/run_lifecycle_suite.ts +208 -0
- package/src/scripts/run_skill_evals.ts +40 -0
- package/src/scripts/schemas/delivery-manifest.schema.json +43 -0
- package/src/scripts/schemas/drafting-channels.schema.json +43 -0
- package/src/scripts/schemas/evals.schema.json +24 -1
- package/src/scripts/schemas/rule.schema.json +52 -1
- package/src/scripts/schemas/skill.schema.json +17 -2
- package/src/scripts/skill_overlap.ts +7 -1
- package/src/scripts/skill_tools/run_block_d_eval.ts +8 -1
- package/src/scripts/skill_tools/score_skill_relevance.ts +84 -3
- package/src/scripts/skill_tools/suggest_skill_for_task.ts +8 -1
- package/src/scripts/skill_usage_report.ts +2 -1
- package/src/scripts/stubs_due.ts +211 -0
- package/src/scripts/sweep_source_surfaces.ts +678 -0
- package/src/scripts/sync_pr_branch.ts +3 -3
- package/src/scripts/trigger_eval_grandfather.json +202 -222
- package/src/scripts/utilization_report.ts +22 -2
- package/src/server/schemas/settings.ts +5 -0
- package/src/templates/marketing-copy.yml +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +0 -26
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collector_denominator — the capture rate's DENOMINATOR, and the spool the
|
|
3
|
+
* numerator is drained from.
|
|
4
|
+
*
|
|
5
|
+
* `road-to-supervised-telemetry-collector` step 1.2 item 1, restated as a
|
|
6
|
+
* constraint on this module: *"the denominator must be produced by a writer
|
|
7
|
+
* that cannot fail in the same way the numerator does; a collector counting its
|
|
8
|
+
* own opportunities is the failure `road-to-journal-host-capture-measurement`
|
|
9
|
+
* exists for."* This is that independent writer, and it also discharges the
|
|
10
|
+
* received item merged into Phase 4 by the AI council on 2026-08-29 — a
|
|
11
|
+
* per-event dispatch counter with no payload and no free-form field, written on
|
|
12
|
+
* hook invocation only.
|
|
13
|
+
*
|
|
14
|
+
* ## Why two writers and not one
|
|
15
|
+
*
|
|
16
|
+
* A collector that counts both halves cannot report its own death. If the
|
|
17
|
+
* daemon is not running, a self-counting instrument records zero opportunities
|
|
18
|
+
* and zero captures, and 0/0 reads as "nothing happened" rather than as "the
|
|
19
|
+
* observer was down". Here the *hook process* writes the denominator
|
|
20
|
+
* synchronously, in-process, with no daemon involved — so a dead collector
|
|
21
|
+
* produces a denominator that keeps climbing and a numerator that does not, and
|
|
22
|
+
* the ratio falls, which is the true statement.
|
|
23
|
+
*
|
|
24
|
+
* The two writers therefore fail differently ON PURPOSE. The denominator fails
|
|
25
|
+
* only if the hook itself does not run; the numerator fails if the daemon is
|
|
26
|
+
* absent, wedged, budget-stopped, opted out, or refusing invalid records.
|
|
27
|
+
*
|
|
28
|
+
* ## What that property does NOT survive: a restart
|
|
29
|
+
*
|
|
30
|
+
* R2 round-4 finding 6, and the correction is to the CLAIM rather than to the
|
|
31
|
+
* code. Captures are spooled to disk while the daemon is down, and the next
|
|
32
|
+
* start replays the whole spool with each record's ORIGINAL `occurred_on` — so
|
|
33
|
+
* the historical rate is repaired retroactively and the outage becomes
|
|
34
|
+
* invisible in the very window the design says would expose it. The two writers
|
|
35
|
+
* fail differently *while the daemon stays down*, not across a restart.
|
|
36
|
+
*
|
|
37
|
+
* That is the right behaviour and the wrong sentence. Dropping the spool on
|
|
38
|
+
* restart would lose real captures to make a graph honest; re-dating them to
|
|
39
|
+
* the drain would move a dispatch that happened on Tuesday into Thursday's
|
|
40
|
+
* denominator. The spool is bounded ({@link SPOOL_MAX_BYTES}) so an unbounded
|
|
41
|
+
* outage still loses records rather than replaying forever, and the reading a
|
|
42
|
+
* human takes from a repaired window is the one this paragraph exists to
|
|
43
|
+
* qualify: a rate that recovered may mean capture recovered, or may mean a
|
|
44
|
+
* daemon came back and flushed. `heartbeat.json`'s three-valued liveness is
|
|
45
|
+
* what distinguishes them, and it is why that file is a state rather than a
|
|
46
|
+
* boolean.
|
|
47
|
+
*
|
|
48
|
+
* ## Never throws, and that is a contract rather than defensive habit
|
|
49
|
+
*
|
|
50
|
+
* `docs/contracts/resident-process-floors.md` § 1 permits observation only, and
|
|
51
|
+
* § 2 Q2 requires that a dispatch the collector cannot serve is DEGRADED, never
|
|
52
|
+
* blocked. A denominator write that can raise would make the observer able to
|
|
53
|
+
* fail a dispatch — which is precisely the falsifiable form § 1 states: *if the
|
|
54
|
+
* module were killed, every dispatch would resolve identically*. Every export
|
|
55
|
+
* here returns a boolean instead of raising, and the boolean is for tests and
|
|
56
|
+
* for the daemon's own diagnostics, never for the dispatch path.
|
|
57
|
+
*
|
|
58
|
+
* ## What is written
|
|
59
|
+
*
|
|
60
|
+
* One tab-separated line per opportunity: UTC **date**, event, platform. No
|
|
61
|
+
* payload, no free-form field, no path, no identifier — the same
|
|
62
|
+
* exclusion-by-construction `collector_record` argues for, applied to a file
|
|
63
|
+
* whose format cannot express anything else. A date rather than a timestamp,
|
|
64
|
+
* for the fingerprinting reason `collector_record.FIELD_PURPOSE` states about
|
|
65
|
+
* `occurred_on`.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import crypto from 'node:crypto';
|
|
69
|
+
import fs from 'node:fs';
|
|
70
|
+
import path from 'node:path';
|
|
71
|
+
|
|
72
|
+
import {
|
|
73
|
+
COLLECTOR_EVENTS,
|
|
74
|
+
COLLECTOR_PLATFORMS,
|
|
75
|
+
COLLECTOR_SCHEMA_VERSION,
|
|
76
|
+
type CollectorEvent,
|
|
77
|
+
type CollectorPlatform,
|
|
78
|
+
} from './collector_record.js';
|
|
79
|
+
import { isOptedOut, resolveCollectorStore, RETENTION_DAYS } from './collector_store.js';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The collector's own version, recorded on every captured record.
|
|
83
|
+
*
|
|
84
|
+
* A literal rather than a read of `package.json`: the record contract calls this
|
|
85
|
+
* the version of the COLLECTOR, and tying it to the package version would make
|
|
86
|
+
* every release look like a collector change to anyone diffing the store.
|
|
87
|
+
*/
|
|
88
|
+
export const COLLECTOR_VERSION = '1.0.0';
|
|
89
|
+
|
|
90
|
+
/** Monotonic per-process counters, declared above their first use — the same
|
|
91
|
+
* rule `UUID_RE` was moved for, applied to the other binding added in the same
|
|
92
|
+
* change (R2 round-5 finding 11). */
|
|
93
|
+
let pruneSequence = 0;
|
|
94
|
+
|
|
95
|
+
/** Append-only opportunity log. One short line per dispatch. */
|
|
96
|
+
export const DENOMINATOR_FILE_NAME = 'opportunities.log';
|
|
97
|
+
/** Where hook processes hand records to the daemon. */
|
|
98
|
+
export const SPOOL_DIR_NAME = 'spool';
|
|
99
|
+
/** The file hook processes append to. Renamed away by the daemon to drain it. */
|
|
100
|
+
export const SPOOL_PENDING_NAME = 'pending.jsonl';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Opt-IN marker. **Absent means off**, which is what "default-off" has to mean
|
|
104
|
+
* to be a property rather than a hope: a fresh install has no marker, so there
|
|
105
|
+
* is no configuration state in which the collector starts by accident.
|
|
106
|
+
*/
|
|
107
|
+
export const ENABLED_MARKER_NAME = 'ENABLED';
|
|
108
|
+
|
|
109
|
+
export function denominatorPath(userRoot?: string): string {
|
|
110
|
+
return path.join(resolveCollectorStore(userRoot).root, DENOMINATOR_FILE_NAME);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function spoolDir(userRoot?: string): string {
|
|
114
|
+
return path.join(resolveCollectorStore(userRoot).root, SPOOL_DIR_NAME);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function spoolPendingPath(userRoot?: string): string {
|
|
118
|
+
return path.join(spoolDir(userRoot), SPOOL_PENDING_NAME);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function enabledMarkerPath(userRoot?: string): string {
|
|
122
|
+
return path.join(resolveCollectorStore(userRoot).root, ENABLED_MARKER_NAME);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Default-off, expressed as the absence of a file.
|
|
127
|
+
*
|
|
128
|
+
* Opt-out still wins over opt-in: a user who has opted out and then somehow
|
|
129
|
+
* acquires an ENABLED marker is opted out. The order matters because the two
|
|
130
|
+
* markers answer different questions — "may this machine capture at all" and
|
|
131
|
+
* "has this user asked for the collector" — and the privacy answer outranks.
|
|
132
|
+
*/
|
|
133
|
+
export function isCollectorEnabled(userRoot?: string): boolean {
|
|
134
|
+
if (isOptedOut(userRoot)) return false;
|
|
135
|
+
// The KILL SWITCH stops collection, not merely the daemon (R2 round-5
|
|
136
|
+
// finding 1). Nothing on the dispatch path read it: with `STOP` in place the
|
|
137
|
+
// hook kept appending opportunities and kept spooling captures, and
|
|
138
|
+
// `drainOnce` replays that spool with each record's original `occurred_on`
|
|
139
|
+
// — so the window an operator believed was stopped was captured
|
|
140
|
+
// retroactively the moment the switch came off. That contradicted two
|
|
141
|
+
// shipped statements outright: the operator page's "Nothing is captured"
|
|
142
|
+
// and `DISPATCH_MODE_CONTRACT`'s "not a second, quieter writer".
|
|
143
|
+
//
|
|
144
|
+
// It belongs HERE rather than at the call site for the reason the
|
|
145
|
+
// self-observation exclusion moved here: a guard in the caller's control
|
|
146
|
+
// flow is one an alias or a refactor can remove without the module noticing.
|
|
147
|
+
if (fs.existsSync(killSwitchPathLocal(userRoot))) return false;
|
|
148
|
+
return fs.existsSync(enabledMarkerPath(userRoot));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The kill-switch marker path.
|
|
153
|
+
*
|
|
154
|
+
* Duplicated from `collector_supervision.killSwitchPath` rather than imported,
|
|
155
|
+
* and the reason is the one thing this module may not do: importing that module
|
|
156
|
+
* would make `collector_denominator` — which the dispatch path loads on EVERY
|
|
157
|
+
* hook invocation — pull in the supervision surface, its heartbeat handling and
|
|
158
|
+
* its process signalling. The literal is one filename, the two are pinned equal
|
|
159
|
+
* by `tests/scripts/collector_self_observation.test.ts`, and a shared constants
|
|
160
|
+
* module for a single string is a third file to keep in sync.
|
|
161
|
+
*/
|
|
162
|
+
function killSwitchPathLocal(userRoot?: string): string {
|
|
163
|
+
return path.join(resolveCollectorStore(userRoot).root, 'STOP');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Create the opt-in marker. Idempotent. */
|
|
167
|
+
export function enableCollector(userRoot?: string): void {
|
|
168
|
+
const target = enabledMarkerPath(userRoot);
|
|
169
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
170
|
+
fs.writeFileSync(
|
|
171
|
+
target,
|
|
172
|
+
'The collector is enabled for this user. Delete this file to return to default-off.\n',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Remove the opt-in marker. Idempotent. */
|
|
177
|
+
export function disableCollector(userRoot?: string): void {
|
|
178
|
+
fs.rmSync(enabledMarkerPath(userRoot), { force: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The one option both writers take.
|
|
183
|
+
*
|
|
184
|
+
* `includeSelfObserved` is a TEST SEAM and is named as one. The exclusion sits
|
|
185
|
+
* inside the writers rather than at the call site, because putting it in the
|
|
186
|
+
* dispatcher's control flow made both `check_static_parity` runs skip the calls
|
|
187
|
+
* entirely and the comparison vacuous (R2 round-4 finding 3). But the same
|
|
188
|
+
* placement means a test — which always runs under `VITEST` — cannot exercise
|
|
189
|
+
* the write path at all without saying so explicitly. Nothing in `src/` passes
|
|
190
|
+
* it, and `tests/scripts/collector_vocabulary_parity.test.ts` asserts the
|
|
191
|
+
* default still excludes.
|
|
192
|
+
*/
|
|
193
|
+
export interface WriterOptions {
|
|
194
|
+
readonly includeSelfObserved?: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The UTC date, `YYYY-MM-DD`. Never a time. */
|
|
198
|
+
export function utcDate(at: number = Date.now()): string {
|
|
199
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Record one dispatch opportunity. Returns whether the line landed.
|
|
204
|
+
*
|
|
205
|
+
* The append is a single `appendFileSync` of a line well under `PIPE_BUF`, so
|
|
206
|
+
* concurrent hook processes interleave whole lines rather than fragments. That
|
|
207
|
+
* is the reason the format is one flat line and not a structure that would need
|
|
208
|
+
* a lock.
|
|
209
|
+
*
|
|
210
|
+
* **Gated on the opt-in MARKER, never on the daemon.** That distinction is the
|
|
211
|
+
* whole independence argument and it is easy to lose: the marker is a file the
|
|
212
|
+
* user created, the daemon is a process that may be absent, wedged,
|
|
213
|
+
* budget-stopped or dead. Counting while the marker exists means a dead daemon
|
|
214
|
+
* produces a climbing denominator and a flat numerator — the capture rate falls
|
|
215
|
+
* and says so. Counting while the DAEMON is up would make the instrument
|
|
216
|
+
* self-reporting and 0/0-blind, which is the failure this module exists for.
|
|
217
|
+
*
|
|
218
|
+
* Gating on the marker at all is a cost decision, and it is the reason a
|
|
219
|
+
* default-off install writes nothing on any hook invocation: **two `existsSync`
|
|
220
|
+
* calls and a return per writer, so FOUR per dispatch once both are wired** — the opt-out probe first, then the ENABLED marker, in
|
|
221
|
+
* that order because the privacy answer outranks. (The first draft of this
|
|
222
|
+
* paragraph said "one `stat`", counting only the marker and forgetting the
|
|
223
|
+
* opt-out check immediately above it — R2 finding 14.) An earlier draft wrote
|
|
224
|
+
* unconditionally so that the ratio would exist for users who never opted in —
|
|
225
|
+
* which is a measurement nobody consented to, paid for by every dispatch on
|
|
226
|
+
* every machine.
|
|
227
|
+
*/
|
|
228
|
+
export function recordOpportunity(
|
|
229
|
+
event: CollectorEvent | string,
|
|
230
|
+
platform: CollectorPlatform | string,
|
|
231
|
+
userRoot?: string,
|
|
232
|
+
at: number = Date.now(),
|
|
233
|
+
opts: WriterOptions = {},
|
|
234
|
+
): boolean {
|
|
235
|
+
try {
|
|
236
|
+
if (opts.includeSelfObserved !== true && isSelfObservation()) return false;
|
|
237
|
+
if (!isCollectorEnabled(userRoot)) return false;
|
|
238
|
+
if (!(COLLECTOR_EVENTS as readonly string[]).includes(event)) return false;
|
|
239
|
+
if (!(COLLECTOR_PLATFORMS as readonly string[]).includes(platform)) return false;
|
|
240
|
+
const target = denominatorPath(userRoot);
|
|
241
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
242
|
+
fs.appendFileSync(target, `${utcDate(at)}\t${event}\t${platform}\n`);
|
|
243
|
+
return true;
|
|
244
|
+
} catch {
|
|
245
|
+
// Observation-only: a failed observation is never a failed dispatch.
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface DenominatorReading {
|
|
251
|
+
readonly total: number;
|
|
252
|
+
readonly byEvent: Readonly<Record<string, number>>;
|
|
253
|
+
/** Lines the parser refused. A silent skip would understate the denominator. */
|
|
254
|
+
readonly malformed: number;
|
|
255
|
+
/** Earliest and latest UTC date seen, or null on an empty read. */
|
|
256
|
+
readonly firstDate: string | null;
|
|
257
|
+
readonly lastDate: string | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ReadWindow {
|
|
261
|
+
/** Inclusive lower bound, `YYYY-MM-DD`. Lines before it are not counted. */
|
|
262
|
+
readonly since?: string;
|
|
263
|
+
/** Inclusive upper bound, `YYYY-MM-DD`. */
|
|
264
|
+
readonly until?: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read the opportunity log. Absent file reads as zero, not as an error.
|
|
271
|
+
*
|
|
272
|
+
* **The window is a parameter now, and its absence was a real defect** (R2
|
|
273
|
+
* round-2 finding 5). Every line carries a UTC date and this function never
|
|
274
|
+
* parsed it, returning a LIFETIME total — while the numerator side is pruned to
|
|
275
|
+
* {@link RETENTION_DAYS} on every daemon iteration. The two halves of the ratio
|
|
276
|
+
* were therefore computed over different time bases, and once the store began
|
|
277
|
+
* pruning, the measured capture rate would have fallen monotonically toward
|
|
278
|
+
* zero regardless of real capture. That is precisely the failure the two-writer
|
|
279
|
+
* design exists to make impossible, arriving through the back door.
|
|
280
|
+
*
|
|
281
|
+
* A date that does not parse is `malformed`, not silently skipped: an
|
|
282
|
+
* understated denominator biases the rate UPWARD.
|
|
283
|
+
*/
|
|
284
|
+
export function readOpportunities(userRoot?: string, window: ReadWindow = {}): DenominatorReading {
|
|
285
|
+
let raw = '';
|
|
286
|
+
try {
|
|
287
|
+
raw = fs.readFileSync(denominatorPath(userRoot), 'utf8');
|
|
288
|
+
} catch {
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
total: 0,
|
|
291
|
+
byEvent: Object.freeze({}),
|
|
292
|
+
malformed: 0,
|
|
293
|
+
firstDate: null,
|
|
294
|
+
lastDate: null,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const byEvent: Record<string, number> = {};
|
|
298
|
+
let total = 0;
|
|
299
|
+
let malformed = 0;
|
|
300
|
+
let firstDate: string | null = null;
|
|
301
|
+
let lastDate: string | null = null;
|
|
302
|
+
for (const line of raw.split('\n')) {
|
|
303
|
+
if (line.length === 0) continue;
|
|
304
|
+
const parts = line.split('\t');
|
|
305
|
+
if (parts.length !== 3 || !(COLLECTOR_EVENTS as readonly string[]).includes(parts[1] ?? '')) {
|
|
306
|
+
malformed += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const date = parts[0] as string;
|
|
310
|
+
if (!DATE_RE.test(date)) {
|
|
311
|
+
malformed += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (window.since !== undefined && date < window.since) continue;
|
|
315
|
+
if (window.until !== undefined && date > window.until) continue;
|
|
316
|
+
const event = parts[1] as string;
|
|
317
|
+
byEvent[event] = (byEvent[event] ?? 0) + 1;
|
|
318
|
+
total += 1;
|
|
319
|
+
if (firstDate === null || date < firstDate) firstDate = date;
|
|
320
|
+
if (lastDate === null || date > lastDate) lastDate = date;
|
|
321
|
+
}
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
total,
|
|
324
|
+
byEvent: Object.freeze(byEvent),
|
|
325
|
+
malformed,
|
|
326
|
+
firstDate,
|
|
327
|
+
lastDate,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Drop opportunity lines older than the retention horizon, rewriting the log.
|
|
333
|
+
*
|
|
334
|
+
* The denominator's half of what `pruneOlderThan` does for the store, and it
|
|
335
|
+
* exists for two reasons that are easy to conflate. The ratio one: both sides
|
|
336
|
+
* must age out on the same clock or the rate drifts by construction. The
|
|
337
|
+
* resource one: nothing bounded this file, and it lives inside the directory
|
|
338
|
+
* `RESOURCE_BUDGETS.disk_bytes` puts a ceiling on — a ceiling whose breach
|
|
339
|
+
* STOPS the collector. An unbounded log in a budgeted directory is a slow
|
|
340
|
+
* self-shutdown.
|
|
341
|
+
*
|
|
342
|
+
* Whole-file rewrite through a temp and a rename, so a crash mid-prune leaves
|
|
343
|
+
* either the old file or the new one and never a truncated log. Returns how
|
|
344
|
+
* many lines were dropped.
|
|
345
|
+
*/
|
|
346
|
+
export function pruneOpportunitiesOlderThan(
|
|
347
|
+
userRoot: string | undefined,
|
|
348
|
+
today: string,
|
|
349
|
+
days: number = RETENTION_DAYS,
|
|
350
|
+
): number {
|
|
351
|
+
const target = denominatorPath(userRoot);
|
|
352
|
+
// The on-disk size BEFORE the read, not after the filter (R2 round-5
|
|
353
|
+
// finding 2). Taking it afterwards made the lost-append window the WHOLE
|
|
354
|
+
// filter — a line appended between the read and the `statSync` was inside
|
|
355
|
+
// `consumed` (so excluded from the carried tail) and absent from `raw` (so
|
|
356
|
+
// excluded from the kept set), i.e. silently dropped — while the comment
|
|
357
|
+
// beneath claimed the window was microseconds. The direction is the
|
|
358
|
+
// dangerous one: an understated denominator biases the rate UPWARD.
|
|
359
|
+
let consumed = 0;
|
|
360
|
+
let raw = '';
|
|
361
|
+
try {
|
|
362
|
+
consumed = fs.statSync(target).size;
|
|
363
|
+
raw = fs.readFileSync(target, 'utf8');
|
|
364
|
+
} catch {
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
const cutoff = new Date(Date.parse(`${today}T00:00:00Z`) - days * 86_400_000)
|
|
368
|
+
.toISOString()
|
|
369
|
+
.slice(0, 10);
|
|
370
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
371
|
+
const kept = lines.filter((l) => {
|
|
372
|
+
const date = l.split('\t')[0] ?? '';
|
|
373
|
+
// A line whose date does not parse is KEPT: it is already counted as
|
|
374
|
+
// malformed by the reader, and deleting what you cannot classify is how
|
|
375
|
+
// a prune quietly becomes a truncation.
|
|
376
|
+
if (!DATE_RE.test(date)) return true;
|
|
377
|
+
return date >= cutoff;
|
|
378
|
+
});
|
|
379
|
+
if (kept.length === lines.length) return 0;
|
|
380
|
+
|
|
381
|
+
// CARRY THE TAIL (R2 round-3 finding 5). Hook processes append to this file
|
|
382
|
+
// from other processes while the prune runs, and a plain read-filter-rename
|
|
383
|
+
// drops everything written into the old inode inside that window. The
|
|
384
|
+
// direction is the dangerous one and this module states it three functions
|
|
385
|
+
// earlier: an understated denominator biases the rate UPWARD, toward making
|
|
386
|
+
// the target look met.
|
|
387
|
+
//
|
|
388
|
+
// So: remember how many bytes we read, and before the rename, append
|
|
389
|
+
// whatever arrived after that offset. A line that lands between the tail
|
|
390
|
+
// read and the rename is still lost — the window is now microseconds rather
|
|
391
|
+
// than the whole filter, and closing it completely needs a lock the
|
|
392
|
+
// append path deliberately does not take.
|
|
393
|
+
// `consumed` is the on-disk size taken BEFORE the read (see above): the
|
|
394
|
+
// stat, not `Buffer.byteLength(raw)`, because that re-encode round-trips
|
|
395
|
+
// through U+FFFD for any invalid byte and misaligns the carried tail
|
|
396
|
+
// (R2 round-4 finding 7).
|
|
397
|
+
let tail = '';
|
|
398
|
+
try {
|
|
399
|
+
const fd = fs.openSync(target, 'r');
|
|
400
|
+
try {
|
|
401
|
+
const size = fs.fstatSync(fd).size;
|
|
402
|
+
if (size > consumed) {
|
|
403
|
+
const buf = Buffer.alloc(size - consumed);
|
|
404
|
+
fs.readSync(fd, buf, 0, buf.length, consumed);
|
|
405
|
+
tail = buf.toString('utf8');
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
fs.closeSync(fd);
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
/* the file vanished; the rename below re-creates it from `kept` */
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A unique temp name, for the reason `claimSpool` was given a counter: the
|
|
415
|
+
// function is exported and driven directly by tests, and two concurrent
|
|
416
|
+
// prunes sharing one staging path clobber each other.
|
|
417
|
+
pruneSequence += 1;
|
|
418
|
+
const tmp = `${target}.${String(process.pid)}.${String(pruneSequence)}.tmp`;
|
|
419
|
+
const body = kept.length === 0 ? '' : `${kept.join('\n')}\n`;
|
|
420
|
+
fs.writeFileSync(tmp, `${body}${tail}`);
|
|
421
|
+
fs.renameSync(tmp, target);
|
|
422
|
+
return lines.length - kept.length;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Is this dispatch the package observing ITSELF?
|
|
427
|
+
*
|
|
428
|
+
* Metric definition item 3 excludes *"dispatches in the package's own test
|
|
429
|
+
* suite and CI (self-observation)"*, and nothing implemented it (R2 round-3
|
|
430
|
+
* finding 3): on an opted-in developer machine every dispatcher test that
|
|
431
|
+
* reached `main` appended to the real `~/.event4u/agent-config` opportunity
|
|
432
|
+
* log. Those dispatches spool no capture, so they biased the measured rate
|
|
433
|
+
* DOWN.
|
|
434
|
+
*
|
|
435
|
+
* Three signals, all set by the RUNNER rather than by this package, so none can
|
|
436
|
+
* be spoofed by the thing being measured: vitest exports `VITEST`, every CI
|
|
437
|
+
* provider exports `CI`, and `NODE_ENV=test` is the conventional third. A
|
|
438
|
+
* consumer's own CI is excluded too, and that is correct — a dispatch from a
|
|
439
|
+
* pipeline is not a dispatch from a session, and the metric is about sessions.
|
|
440
|
+
*
|
|
441
|
+
* It lives here rather than in the dispatcher because it is a statement about
|
|
442
|
+
* the METRIC, not about hook dispatch; the dispatcher merely asks.
|
|
443
|
+
*/
|
|
444
|
+
export function isSelfObservation(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
445
|
+
return env.VITEST !== undefined || env.CI !== undefined || env.NODE_ENV === 'test';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Where the stable per-machine id lives. Generated once, never derived from a host fact. */
|
|
449
|
+
export const MACHINE_ID_NAME = 'machine-id';
|
|
450
|
+
/** Where the per-episode sequence counter lives, keyed by episode. */
|
|
451
|
+
export const EPISODE_DIR_NAME = 'episodes';
|
|
452
|
+
|
|
453
|
+
/** Declared ABOVE its first use: `machineId` reads it, and a TDZ ReferenceError
|
|
454
|
+
* inside a function whose whole contract is never to throw is a defect waiting
|
|
455
|
+
* for a re-order (R2 round-4 finding 11). */
|
|
456
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
457
|
+
|
|
458
|
+
export function machineIdPath(userRoot?: string): string {
|
|
459
|
+
return path.join(resolveCollectorStore(userRoot).root, MACHINE_ID_NAME);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The machine id: read it, or mint one.
|
|
464
|
+
*
|
|
465
|
+
* `FIELD_PURPOSE` requires a LOCALLY GENERATED random UUID and explicitly not a
|
|
466
|
+
* derived one — a hash of a hostname or a username is a pseudonym for the thing
|
|
467
|
+
* it derives from, which is the leak class the whole schema is built against.
|
|
468
|
+
* So this is `randomUUID()` and nothing else, persisted so it is stable across
|
|
469
|
+
* invocations and rotatable by deleting one file.
|
|
470
|
+
*/
|
|
471
|
+
export function machineId(userRoot?: string): string | null {
|
|
472
|
+
const target = machineIdPath(userRoot);
|
|
473
|
+
try {
|
|
474
|
+
const existing = fs.readFileSync(target, 'utf8').trim();
|
|
475
|
+
if (UUID_RE.test(existing)) return existing;
|
|
476
|
+
} catch {
|
|
477
|
+
/* mint below */
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const minted = crypto.randomUUID();
|
|
481
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
482
|
+
fs.writeFileSync(target, `${minted}\n`);
|
|
483
|
+
return minted;
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The episode id for this process.
|
|
491
|
+
*
|
|
492
|
+
* One per session, per `FIELD_PURPOSE`. A hook runs as a fresh process per
|
|
493
|
+
* event, so "this process" is not a session — the id is derived from the host's
|
|
494
|
+
* own session identifier when it exposes one, and is otherwise minted per
|
|
495
|
+
* process, which degrades the SCOPE of `sequence` and nothing else.
|
|
496
|
+
*
|
|
497
|
+
* The host id is HASHED rather than carried: it is an opaque host value, and
|
|
498
|
+
* this schema's rule is that no field holds a foreign identifier. The hash is
|
|
499
|
+
* one-way and the result is shaped as a UUID so `validateRecord` accepts it.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* The per-process fallback episode, minted once.
|
|
503
|
+
*
|
|
504
|
+
* A fresh UUID per CALL was the defect: with no host session id in the
|
|
505
|
+
* environment, two `recordCapture` calls in one process landed in two
|
|
506
|
+
* episodes, so `dedup_key` scoping and the per-episode sequence counter both
|
|
507
|
+
* broke. It was invisible on a developer machine because the host exports
|
|
508
|
+
* `CLAUDE_CODE_SESSION_ID`, and it surfaced only on CI, where nothing does —
|
|
509
|
+
* which is the whole population of unattended runs.
|
|
510
|
+
*
|
|
511
|
+
* Memoised per process, not per file: a hook is one process per event, so the
|
|
512
|
+
* honest degradation without a host session is one episode per event rather
|
|
513
|
+
* than one episode per record. That is a coarser grouping, and it is a
|
|
514
|
+
* grouping.
|
|
515
|
+
*/
|
|
516
|
+
let fallbackEpisode: string | null = null;
|
|
517
|
+
|
|
518
|
+
export function episodeId(env: NodeJS.ProcessEnv = process.env): string {
|
|
519
|
+
const hostSession = env.CLAUDE_CODE_SESSION_ID ?? env.AGENT_CONFIG_SESSION_ID ?? '';
|
|
520
|
+
if (hostSession === '') {
|
|
521
|
+
fallbackEpisode ??= crypto.randomUUID();
|
|
522
|
+
return fallbackEpisode;
|
|
523
|
+
}
|
|
524
|
+
const digest = crypto.createHash('sha256').update(hostSession).digest('hex');
|
|
525
|
+
return [
|
|
526
|
+
digest.slice(0, 8),
|
|
527
|
+
digest.slice(8, 12),
|
|
528
|
+
`4${digest.slice(13, 16)}`,
|
|
529
|
+
`8${digest.slice(17, 20)}`,
|
|
530
|
+
digest.slice(20, 32),
|
|
531
|
+
].join('-');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The next sequence number within an episode.
|
|
536
|
+
*
|
|
537
|
+
* Monotonic and non-negative per `FIELD_PURPOSE`, and it has to survive across
|
|
538
|
+
* PROCESSES because a hook is one process per event. So it is a counter file
|
|
539
|
+
* per episode, incremented under `wx`-free read-then-write — a lost update
|
|
540
|
+
* costs a duplicate `dedup_key`, which the store already collapses at read
|
|
541
|
+
* time, so the failure mode is a duplicate rather than a gap.
|
|
542
|
+
*/
|
|
543
|
+
export function nextSequence(episode: string, userRoot?: string): number {
|
|
544
|
+
const dir = path.join(resolveCollectorStore(userRoot).root, EPISODE_DIR_NAME);
|
|
545
|
+
const target = path.join(dir, `${episode}.seq`);
|
|
546
|
+
try {
|
|
547
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
548
|
+
let current = 0;
|
|
549
|
+
try {
|
|
550
|
+
const raw = Number.parseInt(fs.readFileSync(target, 'utf8').trim(), 10);
|
|
551
|
+
if (Number.isInteger(raw) && raw >= 0) current = raw + 1;
|
|
552
|
+
} catch {
|
|
553
|
+
/* first record of this episode */
|
|
554
|
+
}
|
|
555
|
+
fs.writeFileSync(target, `${current}\n`);
|
|
556
|
+
return current;
|
|
557
|
+
} catch {
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Drop episode counters older than the retention horizon.
|
|
564
|
+
*
|
|
565
|
+
* `nextSequence` writes one `episodes/<id>.seq` per session and nothing pruned
|
|
566
|
+
* the directory (R2 round-4 finding 4) — inside the very directory
|
|
567
|
+
* `RESOURCE_BUDGETS.disk_bytes` ceilings, whose breach STOPS the collector.
|
|
568
|
+
* That is the argument the disk-budget basis already makes for
|
|
569
|
+
* `opportunities.log`, left unapplied to the sibling directory added in the
|
|
570
|
+
* same change. `directorySize` also re-`stat`s every one of these files on
|
|
571
|
+
* every beat, so the per-beat cost grew without bound too.
|
|
572
|
+
*
|
|
573
|
+
* Pruned by MTIME rather than by content: a counter file carries a number, not
|
|
574
|
+
* a date, and inventing a date format for it would be a second thing to keep in
|
|
575
|
+
* sync with the retention horizon.
|
|
576
|
+
*/
|
|
577
|
+
export function pruneEpisodeCounters(
|
|
578
|
+
userRoot: string | undefined,
|
|
579
|
+
now: number = Date.now(),
|
|
580
|
+
days: number = RETENTION_DAYS,
|
|
581
|
+
): number {
|
|
582
|
+
const dir = path.join(resolveCollectorStore(userRoot).root, EPISODE_DIR_NAME);
|
|
583
|
+
let entries: string[];
|
|
584
|
+
try {
|
|
585
|
+
entries = fs.readdirSync(dir);
|
|
586
|
+
} catch {
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
const cutoff = now - days * 86_400_000;
|
|
590
|
+
let dropped = 0;
|
|
591
|
+
for (const name of entries) {
|
|
592
|
+
if (!name.endsWith('.seq')) continue;
|
|
593
|
+
const full = path.join(dir, name);
|
|
594
|
+
try {
|
|
595
|
+
if (fs.statSync(full).mtimeMs >= cutoff) continue;
|
|
596
|
+
fs.rmSync(full, { force: true });
|
|
597
|
+
dropped += 1;
|
|
598
|
+
} catch {
|
|
599
|
+
/* raced with another prune */
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return dropped;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Record a CAPTURE — the numerator's production writer.
|
|
607
|
+
*
|
|
608
|
+
* **This function is the fix for the defect three review rounds took to find**
|
|
609
|
+
* (R2 round-3 finding 1): `spoolRecord` had no production caller at all. The
|
|
610
|
+
* denominator was wired into `dispatch_hook.main` and climbed; the numerator
|
|
611
|
+
* stayed 0 by construction, so once the observation window and sample
|
|
612
|
+
* thresholds were met, the miss branch would have fired a decision record for a
|
|
613
|
+
* WIRING OMISSION rather than for a capture failure — and the two are
|
|
614
|
+
* indistinguishable from the number.
|
|
615
|
+
*
|
|
616
|
+
* It builds a valid record from the three facts a hook has (event, platform,
|
|
617
|
+
* date) plus the three the store needs to deduplicate (machine, episode,
|
|
618
|
+
* sequence), and hands it to {@link spoolRecord}. Same never-throw contract as
|
|
619
|
+
* everything else on this path.
|
|
620
|
+
*/
|
|
621
|
+
export function recordCapture(
|
|
622
|
+
event: CollectorEvent | string,
|
|
623
|
+
platform: CollectorPlatform | string,
|
|
624
|
+
userRoot?: string,
|
|
625
|
+
at: number = Date.now(),
|
|
626
|
+
opts: WriterOptions = {},
|
|
627
|
+
): boolean {
|
|
628
|
+
try {
|
|
629
|
+
if (opts.includeSelfObserved !== true && isSelfObservation()) return false;
|
|
630
|
+
if (!isCollectorEnabled(userRoot)) return false;
|
|
631
|
+
if (!(COLLECTOR_EVENTS as readonly string[]).includes(event)) return false;
|
|
632
|
+
if (!(COLLECTOR_PLATFORMS as readonly string[]).includes(platform)) return false;
|
|
633
|
+
const machine = machineId(userRoot);
|
|
634
|
+
if (machine === null) return false;
|
|
635
|
+
const episode = episodeId();
|
|
636
|
+
return spoolRecord(
|
|
637
|
+
{
|
|
638
|
+
schema_version: COLLECTOR_SCHEMA_VERSION,
|
|
639
|
+
machine_id: machine,
|
|
640
|
+
episode_id: episode,
|
|
641
|
+
event,
|
|
642
|
+
sequence: nextSequence(episode, userRoot),
|
|
643
|
+
outcome: 'captured',
|
|
644
|
+
platform,
|
|
645
|
+
occurred_on: utcDate(at),
|
|
646
|
+
collector_version: COLLECTOR_VERSION,
|
|
647
|
+
},
|
|
648
|
+
userRoot,
|
|
649
|
+
);
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Hand a record to the daemon. Returns whether it was spooled.
|
|
657
|
+
*
|
|
658
|
+
* The hook does NOT write to the SQLite store. Short-lived processes opening a
|
|
659
|
+
* database on every dispatch is the write contention and the version-skew risk
|
|
660
|
+
* the daemon exists to remove, and it would put a `node:sqlite` dependency on
|
|
661
|
+
* the dispatch path — which the observation-only contract's "resolve
|
|
662
|
+
* identically" clause cannot survive on a runtime without it.
|
|
663
|
+
*
|
|
664
|
+
* Refuses when the collector is off, so a disabled collector accumulates no
|
|
665
|
+
* spool to drain later. That is deliberate: a spool that survives the disabled
|
|
666
|
+
* period would make a re-enable look like a capture spike.
|
|
667
|
+
*/
|
|
668
|
+
/**
|
|
669
|
+
* Hard ceiling on the undrained spool, in bytes.
|
|
670
|
+
*
|
|
671
|
+
* The spool is written by hook processes and drained ONLY by the daemon, and
|
|
672
|
+
* "collector enabled, daemon not running" is a supported state — the daemon is
|
|
673
|
+
* a separate opt-in process and `start()` legitimately refuses on `lock-held`
|
|
674
|
+
* or `store-unavailable`. Without a cap the spool grew without limit in exactly
|
|
675
|
+
* that state, and the disk budget that would have noticed is enforced by the
|
|
676
|
+
* process that is not running (R2 round-4 finding 5).
|
|
677
|
+
*
|
|
678
|
+
* 8 MiB is two thirds of the disk budget's 12 MiB expected peak, so a spool at
|
|
679
|
+
* its ceiling still leaves the store's own share. A record is ~250 bytes, so
|
|
680
|
+
* this is roughly 33,000 undrained captures — far past any plausible drain
|
|
681
|
+
* interval, which is what makes hitting it a signal rather than a limit.
|
|
682
|
+
*/
|
|
683
|
+
export const SPOOL_MAX_BYTES = 8 * 1024 * 1024;
|
|
684
|
+
|
|
685
|
+
export function spoolRecord(record: unknown, userRoot?: string): boolean {
|
|
686
|
+
try {
|
|
687
|
+
if (!isCollectorEnabled(userRoot)) return false;
|
|
688
|
+
const target = spoolPendingPath(userRoot);
|
|
689
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
690
|
+
// Refuse rather than rotate. A rotated spool would keep the newest
|
|
691
|
+
// captures and silently drop the oldest, which inflates the measured
|
|
692
|
+
// rate for the window that follows an outage; refusing loses the same
|
|
693
|
+
// records and does not pretend otherwise. Either way the loss is real —
|
|
694
|
+
// the difference is whether the numerator lies about it.
|
|
695
|
+
try {
|
|
696
|
+
if (fs.statSync(target).size >= SPOOL_MAX_BYTES) return false;
|
|
697
|
+
} catch {
|
|
698
|
+
/* absent is size 0 */
|
|
699
|
+
}
|
|
700
|
+
fs.appendFileSync(target, `${JSON.stringify(record)}\n`);
|
|
701
|
+
return true;
|
|
702
|
+
} catch {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Atomically claim the pending spool for draining, returning the claimed path
|
|
709
|
+
* or null when there is nothing to drain.
|
|
710
|
+
*
|
|
711
|
+
* The rename is what makes concurrent appends safe: writers that already have
|
|
712
|
+
* the old inode finish into the claimed file, and every writer that opens after
|
|
713
|
+
* the rename creates a fresh `pending.jsonl`. No lock, no lost line.
|
|
714
|
+
*/
|
|
715
|
+
let claimSequence = 0;
|
|
716
|
+
|
|
717
|
+
export function claimSpool(userRoot?: string, at: number = Date.now()): string | null {
|
|
718
|
+
const pending = spoolPendingPath(userRoot);
|
|
719
|
+
if (!fs.existsSync(pending)) return null;
|
|
720
|
+
// A monotonic per-process counter, because the timestamp alone is not
|
|
721
|
+
// unique (R2 round-2 finding 14): two claims by one process inside the same
|
|
722
|
+
// millisecond produced the same name, and `renameSync` overwrites silently
|
|
723
|
+
// — so the earlier batch was destroyed rather than the collision being
|
|
724
|
+
// noticed. The per-user lock makes that narrow in production, but both
|
|
725
|
+
// functions are exported and driven directly by tests, and the docstring's
|
|
726
|
+
// "no lock, no lost line" is only true if the claimed name is unique.
|
|
727
|
+
claimSequence += 1;
|
|
728
|
+
const claimed = path.join(
|
|
729
|
+
spoolDir(userRoot),
|
|
730
|
+
`draining-${at}-${process.pid}-${claimSequence}.jsonl`,
|
|
731
|
+
);
|
|
732
|
+
try {
|
|
733
|
+
fs.renameSync(pending, claimed);
|
|
734
|
+
return claimed;
|
|
735
|
+
} catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Parse a claimed spool file into records. Unparseable lines are reported,
|
|
742
|
+
* never dropped silently — and an UNREADABLE FILE is reported too.
|
|
743
|
+
*
|
|
744
|
+
* `unreadable` exists because its absence destroyed batches (R2 round-3 finding
|
|
745
|
+
* 2): a read error returned `{records: [], malformed: 0}`, which is
|
|
746
|
+
* indistinguishable from an empty file, and the caller then deleted the batch.
|
|
747
|
+
* A transient `EACCES` or `EMFILE` therefore lost every record in it and
|
|
748
|
+
* reported nothing — the exact loss class the crash-recovery re-drain exists to
|
|
749
|
+
* prevent, arriving through the recovery path itself.
|
|
750
|
+
*/
|
|
751
|
+
export function readClaimedSpool(
|
|
752
|
+
claimed: string,
|
|
753
|
+
): { records: unknown[]; malformed: number; unreadable: boolean } {
|
|
754
|
+
let raw = '';
|
|
755
|
+
try {
|
|
756
|
+
raw = fs.readFileSync(claimed, 'utf8');
|
|
757
|
+
} catch {
|
|
758
|
+
return { records: [], malformed: 0, unreadable: true };
|
|
759
|
+
}
|
|
760
|
+
const records: unknown[] = [];
|
|
761
|
+
let malformed = 0;
|
|
762
|
+
for (const line of raw.split('\n')) {
|
|
763
|
+
if (line.length === 0) continue;
|
|
764
|
+
try {
|
|
765
|
+
records.push(JSON.parse(line));
|
|
766
|
+
} catch {
|
|
767
|
+
malformed += 1;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { records, malformed, unreadable: false };
|
|
771
|
+
}
|