@herbertgao/pi-extensions 2026.9.0 → 2026.9.2
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/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
- package/node_modules/@czottmann/pi-automode/README.md +20 -0
- package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
- package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
- package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
- package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
- package/node_modules/@czottmann/pi-automode/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/README.md +57 -141
- package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
- package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
- package/node_modules/@narumitw/pi-btw/package.json +2 -1
- package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
- package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
- package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
- package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
- package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
- package/node_modules/pi-lens/CHANGELOG.md +1072 -0
- package/node_modules/pi-lens/README.md +3 -0
- package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
- package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
- package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
- package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
- package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
- package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
- package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
- package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -3
- package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
- package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -6
- package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
- package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
- package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -9
- package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
- package/node_modules/pi-lens/dist/clients/bus-events-logger.js +3 -11
- package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
- package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
- package/node_modules/pi-lens/dist/clients/cache-observability.js +40 -19
- package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
- package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
- package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
- package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
- package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
- package/node_modules/pi-lens/dist/clients/config-core/index.js +50 -0
- package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
- package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
- package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
- package/node_modules/pi-lens/dist/clients/config-core/provenance.js +164 -0
- package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
- package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
- package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
- package/node_modules/pi-lens/dist/clients/config-core/schema.js +165 -0
- package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
- package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
- package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
- package/node_modules/pi-lens/dist/clients/config-schema.js +234 -0
- package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
- package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
- package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -6
- package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
- package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
- package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
- package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
- package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
- package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
- package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
- package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +15 -5
- package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
- package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -44
- package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
- package/node_modules/pi-lens/dist/clients/dispatch/integration.js +27 -8
- package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
- package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
- package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +383 -27
- package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +14 -18
- package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -2
- package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -67
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
- package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
- package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -10
- package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
- package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
- package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
- package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
- package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
- package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
- package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
- package/node_modules/pi-lens/dist/clients/file-time.js +7 -2
- package/node_modules/pi-lens/dist/clients/file-utils.js +117 -10
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
- package/node_modules/pi-lens/dist/clients/format-service.js +6 -2
- package/node_modules/pi-lens/dist/clients/formatters.js +385 -104
- package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
- package/node_modules/pi-lens/dist/clients/generated-artifacts.js +116 -22
- package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
- package/node_modules/pi-lens/dist/clients/git-guard.js +6 -1
- package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
- package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
- package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
- package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
- package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
- package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
- package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
- package/node_modules/pi-lens/dist/clients/installer/index.js +289 -59
- package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
- package/node_modules/pi-lens/dist/clients/instance-reaper.js +110 -236
- package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
- package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
- package/node_modules/pi-lens/dist/clients/language-registry.js +599 -0
- package/node_modules/pi-lens/dist/clients/latency-logger.js +165 -9
- package/node_modules/pi-lens/dist/clients/ledger-bounds.js +34 -1
- package/node_modules/pi-lens/dist/clients/lens-config.js +160 -30
- package/node_modules/pi-lens/dist/clients/lens-engine.js +33 -41
- package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
- package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +77 -6
- package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
- package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -38
- package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
- package/node_modules/pi-lens/dist/clients/lsp/client.js +172 -33
- package/node_modules/pi-lens/dist/clients/lsp/config.js +484 -158
- package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/edits.js +18 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +892 -96
- package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -177
- package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -18
- package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
- package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
- package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
- package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
- package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +14 -7
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
- package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
- package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
- package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
- package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
- package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
- package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
- package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
- package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
- package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +3 -5
- package/node_modules/pi-lens/dist/clients/module-report.js +21 -46
- package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
- package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
- package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -24
- package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
- package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
- package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +70 -28
- package/node_modules/pi-lens/dist/clients/opengrep-config.js +5 -1
- package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
- package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
- package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
- package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
- package/node_modules/pi-lens/dist/clients/path-utils.js +451 -15
- package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
- package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
- package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
- package/node_modules/pi-lens/dist/clients/pipeline.js +79 -18
- package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
- package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
- package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
- package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
- package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +28 -4
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
- package/node_modules/pi-lens/dist/clients/project-lens-config.js +438 -122
- package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
- package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
- package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
- package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
- package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
- package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
- package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +16 -5
- package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
- package/node_modules/pi-lens/dist/clients/read-guard.js +26 -2
- package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
- package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +155 -114
- package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
- package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
- package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
- package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
- package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +19 -3
- package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
- package/node_modules/pi-lens/dist/clients/runtime-session.js +184 -32
- package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +179 -36
- package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
- package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
- package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
- package/node_modules/pi-lens/dist/clients/sanitize.js +7 -12
- package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
- package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
- package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
- package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
- package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
- package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
- package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
- package/node_modules/pi-lens/dist/clients/sgconfig.js +7 -6
- package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
- package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
- package/node_modules/pi-lens/dist/clients/smells-rollup.js +4 -4
- package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
- package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
- package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
- package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
- package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +244 -0
- package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
- package/node_modules/pi-lens/dist/clients/tool-policy.js +92 -22
- package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
- package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +87 -100
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
- package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
- package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
- package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
- package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
- package/node_modules/pi-lens/dist/clients/user-notify.js +6 -6
- package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
- package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -9
- package/node_modules/pi-lens/dist/clients/word-index-store.js +23 -12
- package/node_modules/pi-lens/dist/clients/word-index.js +252 -47
- package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
- package/node_modules/pi-lens/dist/clients/zizmor-config.js +4 -1
- package/node_modules/pi-lens/dist/index.js +65899 -55340
- package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
- package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
- package/node_modules/pi-lens/dist/mcp/server.js +127 -5
- package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
- package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +96 -25
- package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +104 -86
- package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
- package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
- package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
- package/node_modules/pi-lens/dist/tools/shared.js +0 -1
- package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
- package/node_modules/pi-lens/docs/agent-guide.md +64 -1
- package/node_modules/pi-lens/docs/configuration.md +222 -0
- package/node_modules/pi-lens/docs/dependencies.md +3 -3
- package/node_modules/pi-lens/docs/features.md +44 -5
- package/node_modules/pi-lens/docs/globalconfig.md +20 -0
- package/node_modules/pi-lens/docs/language-coverage.md +2 -2
- package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
- package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
- package/node_modules/pi-lens/docs/pi-lens-reviewer.md +33 -0
- package/node_modules/pi-lens/docs/pi-lens-subagent.md +49 -0
- package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
- package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
- package/node_modules/pi-lens/docs/release-qa-baseline.md +190 -0
- package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
- package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
- package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
- package/node_modules/pi-lens/docs/word-index.md +1 -1
- package/node_modules/pi-lens/package.json +18 -35
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
- package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
- package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
- package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
- package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
- package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
- package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
- package/node_modules/pi-web-access/CHANGELOG.md +28 -0
- package/node_modules/pi-web-access/README.md +62 -15
- package/node_modules/pi-web-access/curator-page.ts +3 -1
- package/node_modules/pi-web-access/curator-server.ts +5 -1
- package/node_modules/pi-web-access/gemini-search.ts +8 -4
- package/node_modules/pi-web-access/github-extract.ts +242 -1
- package/node_modules/pi-web-access/index.ts +118 -64
- package/node_modules/pi-web-access/mistral-search.ts +281 -0
- package/node_modules/pi-web-access/package.json +2 -2
- package/node_modules/pi-web-access/perplexity.ts +14 -1
- package/node_modules/pi-web-access/utils.ts +23 -6
- package/node_modules/pi-web-access/xai-search.ts +96 -33
- package/package.json +6 -6
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observational mutation net (#2430).
|
|
3
|
+
*
|
|
4
|
+
* ## What it is
|
|
5
|
+
*
|
|
6
|
+
* `clients/mutating-tool.ts` classifies a mutation by NAME or by input SHAPE.
|
|
7
|
+
* Both tiers are finite and the population of third-party edit tools is not, so
|
|
8
|
+
* a tool pi-lens has never met is dropped before the first bookkeeping call —
|
|
9
|
+
* no read-guard stamp, no `turn-state.json` entry, no deferred format.
|
|
10
|
+
*
|
|
11
|
+
* This module makes detection OBSERVATIONAL. It watches a bounded file set
|
|
12
|
+
* around a call the seam could not classify, and if something changed it
|
|
13
|
+
* replays the change through the mutation bridge as a real `kind: "edit"`. The
|
|
14
|
+
* tool is then ATTRIBUTED (`clients/mutation-attribution.ts`), so a later
|
|
15
|
+
* session on the same project classifies it from disk with no snapshot at all.
|
|
16
|
+
*
|
|
17
|
+
* Three layers, cheapest first:
|
|
18
|
+
*
|
|
19
|
+
* 1. **Nothing at all** for a tool the seam already classifies. `arm` is never
|
|
20
|
+
* reached: `runtime-tool-call.ts` only calls in when `classifyMutatingTool`
|
|
21
|
+
* returned `undefined` or the attribution is still provisional, and the
|
|
22
|
+
* first thing `arm` does is a map lookup.
|
|
23
|
+
* 2. **Arm + diff** for an unclassified call whose input carries a path-shaped
|
|
24
|
+
* field, bounded to THAT PATH ALONE. Paid at most a handful of times per
|
|
25
|
+
* tool name per session (see `CLEAN_OBSERVATION_ARM_LIMIT` and
|
|
26
|
+
* `PERSIST_AFTER_OBSERVATIONS`).
|
|
27
|
+
* 3. **The settled sweep** at `agent_settled`, before the deferred drain, for
|
|
28
|
+
* tools with no path field at all. It stat-checks the tracked-file set
|
|
29
|
+
* incrementally and NEVER walks the workspace.
|
|
30
|
+
*
|
|
31
|
+
* ## The observation universe is the TARGET PATH (#2449 review round 2)
|
|
32
|
+
*
|
|
33
|
+
* The first cut snapshotted the target's whole DIRECTORY plus the tracked-file
|
|
34
|
+
* set. That was wrong in both directions and expensive in a third:
|
|
35
|
+
*
|
|
36
|
+
* - it attributed a SIBLING's change to the tool under observation, so a
|
|
37
|
+
* background write during a `read`-shaped call taught pi-lens that `read`
|
|
38
|
+
* mutates (round-2 finding F4);
|
|
39
|
+
* - it made the arm cost scale with directory size and tracked-set size
|
|
40
|
+
* (~44ms warm), for a verdict about ONE path;
|
|
41
|
+
* - it duplicated the settled sweep's job. The tracked set is the SWEEP's
|
|
42
|
+
* domain; the armed observation's domain is the path the tool named.
|
|
43
|
+
*
|
|
44
|
+
* So the universe is now: the path-shaped field's file, or — when that path is
|
|
45
|
+
* a DIRECTORY — that directory's own entries, non-recursively, capped at
|
|
46
|
+
* {@link OBSERVED_TARGET_DIR_MAX_ENTRIES}. Nothing else. A tool that changes a
|
|
47
|
+
* file it never named is the settled sweep's business, not the armed
|
|
48
|
+
* observation's, and the sweep says so honestly rather than guessing.
|
|
49
|
+
*
|
|
50
|
+
* When that cap BITES the observation is truncated, and a truncated
|
|
51
|
+
* observation is `unverifiable` — never clean (#2449 review round 3, S3). The
|
|
52
|
+
* first cut broke out of the readdir loop silently, so a codemod that rewrote
|
|
53
|
+
* the 84th entry of an 84-entry directory produced an empty diff, the empty
|
|
54
|
+
* diff advanced the clean latch, and two of those stopped pi-lens watching a
|
|
55
|
+
* tool that mutates on every call.
|
|
56
|
+
*
|
|
57
|
+
* ## What layer 3 cannot see, stated rather than hidden
|
|
58
|
+
*
|
|
59
|
+
* The sweep compares against a ledger seeded from files pi-lens has ALREADY
|
|
60
|
+
* seen. A file that was never read, never written, never diagnosed and never
|
|
61
|
+
* opened by a language server has no baseline, so its first drift only seeds
|
|
62
|
+
* the ledger and is not reported. That is the documented limitation in #2430's
|
|
63
|
+
* third acceptance criterion; the alternative is a workspace walk, which the
|
|
64
|
+
* issue rules out.
|
|
65
|
+
*
|
|
66
|
+
* The sweep is also INCREMENTAL (round-2 finding F3). A full hash of 400
|
|
67
|
+
* tracked files cannot finish inside a 200ms capture budget, so the first cut
|
|
68
|
+
* timed out at every realistic size and never ran at all. It now stats a
|
|
69
|
+
* bounded window per turn from a carried cursor, reads a file only when its
|
|
70
|
+
* size or mtime actually moved, and reports its own coverage
|
|
71
|
+
* (`scanned`/`remaining`/`cursor`) so a partial pass is never read as a clean
|
|
72
|
+
* one.
|
|
73
|
+
*
|
|
74
|
+
* ## Bounds (AGENTS.md async rule, both directions)
|
|
75
|
+
*
|
|
76
|
+
* Every ASYNC step here carries a TIMEOUT and an `AbortSignal` race, and every
|
|
77
|
+
* capture is additionally bounded by a file cap and a hash-byte budget. The
|
|
78
|
+
* ARM shares a per-turn wall-clock budget; exhausting it emits a bounded
|
|
79
|
+
* `observed_mutation_budget_exhausted` record and a degradation-ledger tally —
|
|
80
|
+
* it is never a silent skip (catalog shape 10).
|
|
81
|
+
*
|
|
82
|
+
* The SETTLE is ASYNC and deliberately NOT budget-gated. Round 2 made it
|
|
83
|
+
* synchronous to keep `handleToolResult` from yielding before it dispatches
|
|
84
|
+
* the pipeline (#1086); round 3 (T4) removed the sync filesystem work from the
|
|
85
|
+
* tool_result path instead and made the ORDER explicit at the call site: the
|
|
86
|
+
* classified chain reads everything it derives from the post-result bytes
|
|
87
|
+
* BEFORE the settle's yield, so a racing tool_result for the same path cannot
|
|
88
|
+
* make this call register under the other call's state hash. Only the
|
|
89
|
+
* pending-baseline PROBE (`hasPendingObservation`) stays synchronous, which is
|
|
90
|
+
* what keeps the cost on the overwhelmingly common no-baseline path at one map
|
|
91
|
+
* lookup.
|
|
92
|
+
*
|
|
93
|
+
* A settle clamped to a spent budget silently dropped real mutations (round-2
|
|
94
|
+
* findings F1 and F5), so the settle has its own deadline rather than the
|
|
95
|
+
* arm's leftovers. The snapshot already exists and the post-capture always
|
|
96
|
+
* runs its first entry, so it completes for the target path whatever the clock
|
|
97
|
+
* says; a directory target's remaining entries are the only part a deadline
|
|
98
|
+
* can cut, and a cut capture says so rather than scoring itself clean.
|
|
99
|
+
*/
|
|
100
|
+
import * as fs from "node:fs";
|
|
101
|
+
import * as path from "node:path";
|
|
102
|
+
import { createHash } from "node:crypto";
|
|
103
|
+
import { BoundedFifoMap, BoundedSet } from "./bounded-cache.js";
|
|
104
|
+
import { emitBounded } from "./bounded-telemetry.js";
|
|
105
|
+
import { freshnessFromMtime } from "./freshness.js";
|
|
106
|
+
import { logLatency } from "./latency-logger.js";
|
|
107
|
+
import { noteObservedClean, noteObservedMutation, noteObservedUnverifiable, shouldArmObservationForTool, } from "./mutation-attribution.js";
|
|
108
|
+
import { captureFileStatsForPaths, diffFileStats, } from "./opaque-mutation-scan.js";
|
|
109
|
+
import { normalizeMapKey } from "./path-utils.js";
|
|
110
|
+
import { getProcessSingleton } from "./process-singletons.js";
|
|
111
|
+
import { bounded } from "./deadline-utils.js";
|
|
112
|
+
import { lineContentHash } from "./read-guard.js";
|
|
113
|
+
/**
|
|
114
|
+
* Wall-clock CEILING for the ARM capture — not its cost.
|
|
115
|
+
*
|
|
116
|
+
* Since the universe collapsed to the target path this is one `stat`, one
|
|
117
|
+
* optional `readdir`, and a bounded set of stat+hash pairs. The ceiling is
|
|
118
|
+
* generous on purpose: a slow filesystem (#462 measured 1.3ms per stat on 9p)
|
|
119
|
+
* must not turn every first observation into a timeout, which is the least
|
|
120
|
+
* useful possible outcome.
|
|
121
|
+
*/
|
|
122
|
+
const OBSERVED_CAPTURE_BUDGET_MS = 200;
|
|
123
|
+
/**
|
|
124
|
+
* Cumulative ceiling for every ARMED capture in one turn. A turn that calls
|
|
125
|
+
* twenty unclassified tools pays this once, not twenty times. The SETTLE does
|
|
126
|
+
* not consult it — see the module header.
|
|
127
|
+
*/
|
|
128
|
+
export const OBSERVED_TURN_BUDGET_MS = 600;
|
|
129
|
+
/**
|
|
130
|
+
* Per-entry deadline for the settle's re-capture.
|
|
131
|
+
*
|
|
132
|
+
* Tighter than {@link OBSERVED_CAPTURE_BUDGET_MS} because the settle sits on
|
|
133
|
+
* the `tool_result` path, between a tool finishing and pi-lens dispatching its
|
|
134
|
+
* pipeline: every millisecond here is latency the agent waits through. Measured
|
|
135
|
+
* on this repo: a file target settles in ~1.4ms and a full 42-entry DIRECTORY
|
|
136
|
+
* target in ~20ms, so this clears the realistic worst case and only bites on a
|
|
137
|
+
* filesystem far slower than the one it was measured on.
|
|
138
|
+
*
|
|
139
|
+
* It can never cut the TARGET itself — `captureFileStatsForPaths` always runs
|
|
140
|
+
* its first entry — so F5's "the settle always completes for the target path"
|
|
141
|
+
* holds regardless of the clock. What it CAN cut is a directory target's tail,
|
|
142
|
+
* and that is reported as `stoppedEarly` rather than scored as clean.
|
|
143
|
+
*/
|
|
144
|
+
const OBSERVED_SETTLE_DEADLINE_MS = 50;
|
|
145
|
+
/**
|
|
146
|
+
* Entries taken from a DIRECTORY-shaped target path, non-recursively.
|
|
147
|
+
*
|
|
148
|
+
* A tool that names a directory is saying "I operate in here"; its own entries
|
|
149
|
+
* are the honest universe. Sixty-four is a blast-radius bound, not a guess
|
|
150
|
+
* about directory sizes: past it the observation degrades to the entries it
|
|
151
|
+
* did see, and the settled sweep remains the net for the rest.
|
|
152
|
+
*/
|
|
153
|
+
export const OBSERVED_TARGET_DIR_MAX_ENTRIES = 64;
|
|
154
|
+
/** Tracked files (read-guard + widget + open LSP docs) the sweep may hold. */
|
|
155
|
+
export const OBSERVED_TRACKED_MAX_FILES = 400;
|
|
156
|
+
/**
|
|
157
|
+
* How long a file must have been QUIET before its ledger entry may be trusted
|
|
158
|
+
* for the sweep's stat short-circuit.
|
|
159
|
+
*
|
|
160
|
+
* Its own constant, not `OPAQUE_MTIME_TOLERANCE_MS` (#2449 review round 3,
|
|
161
|
+
* S1). That one answers "how far before a recorded start may an earlier write
|
|
162
|
+
* still be attributed to this call" — an attribution window. This one answers
|
|
163
|
+
* "was the file still being written when we took its baseline" — a settling
|
|
164
|
+
* window. They happen to share a number today; a change to either for its own
|
|
165
|
+
* reasons must not move the other.
|
|
166
|
+
*
|
|
167
|
+
* ## The direction it does NOT close, stated
|
|
168
|
+
*
|
|
169
|
+
* On a filesystem whose mtime granularity is COARSER than this window (FAT's
|
|
170
|
+
* two seconds, HFS+'s one), a baseline recorded 150ms or more into a tick and
|
|
171
|
+
* a same-size rewrite later in that SAME tick still short-circuit: the entry
|
|
172
|
+
* looks settled because the mtime it carries is old, and the rewrite does not
|
|
173
|
+
* move it. That residual window is the price of the short-circuit that makes
|
|
174
|
+
* the sweep affordable at all. The settled sweep's next pass over the file
|
|
175
|
+
* closes it as soon as anything moves the size or the tick, and the armed
|
|
176
|
+
* observation (which hashes the target unconditionally) never depended on it.
|
|
177
|
+
*/
|
|
178
|
+
const OBSERVED_LEDGER_SETTLE_MS = 150;
|
|
179
|
+
/**
|
|
180
|
+
* Path keys the "pi-lens already recorded this" set may hold.
|
|
181
|
+
*
|
|
182
|
+
* Marks are normally retired per file by {@link refreshObservedMutationLedger},
|
|
183
|
+
* as it re-baselines each one. A refresh that keeps parking before it reaches
|
|
184
|
+
* the tail leaves the tail's marks standing indefinitely, so the set still needs
|
|
185
|
+
* a bound of its own — and, because dropping a mark is not free (it makes the
|
|
186
|
+
* next sweep read pi-lens's own bytes as third-party drift), the drop emits
|
|
187
|
+
* `observed_handled_evicted` naming the path (#2449 review round 4, S2).
|
|
188
|
+
*/
|
|
189
|
+
export const OBSERVED_HANDLED_MAX = 1000;
|
|
190
|
+
/**
|
|
191
|
+
* Files the settled sweep STATS in one turn before parking its cursor.
|
|
192
|
+
*
|
|
193
|
+
* Smaller than {@link OBSERVED_TRACKED_MAX_FILES} on purpose: coverage of the
|
|
194
|
+
* whole tracked set is spread across turns rather than attempted (and timed
|
|
195
|
+
* out) in one. The read-guard's own set is ordered first by
|
|
196
|
+
* `collectTrackedPaths`, so the files the agent is actually working on are at
|
|
197
|
+
* the front of the rotation.
|
|
198
|
+
*/
|
|
199
|
+
export const OBSERVED_SWEEP_STAT_WINDOW = 128;
|
|
200
|
+
/** Cumulative content-hash budget for ONE capture. */
|
|
201
|
+
const OBSERVED_HASH_BUDGET_BYTES = 2 * 1024 * 1024;
|
|
202
|
+
/** Cumulative bytes ONE settled sweep may read to seed or verify a hash. */
|
|
203
|
+
export const OBSERVED_SWEEP_HASH_BUDGET_BYTES = 2 * 1024 * 1024;
|
|
204
|
+
/** Cumulative bytes ONE settled sweep may read to derive edit RANGES. */
|
|
205
|
+
const OBSERVED_SWEEP_RANGE_BUDGET_BYTES = 1024 * 1024;
|
|
206
|
+
/** Largest file whose per-line hashes are captured for range derivation. */
|
|
207
|
+
const OBSERVED_LINE_HASH_MAX_BYTES = 512 * 1024;
|
|
208
|
+
/** Ranges reported per file before they collapse to one bounding box. */
|
|
209
|
+
const OBSERVED_MAX_EDIT_RANGES = 32;
|
|
210
|
+
/** Pending baselines held between `tool_call` and `tool_result`. */
|
|
211
|
+
const OBSERVED_PENDING_MAX = 32;
|
|
212
|
+
/** Files remembered by the settled-sweep content ledger. */
|
|
213
|
+
const OBSERVED_LEDGER_MAX = 1000;
|
|
214
|
+
const OBSERVED_FAMILY = "observed-mutation-net";
|
|
215
|
+
const OBSERVED_VERSION = 3;
|
|
216
|
+
function state() {
|
|
217
|
+
return getProcessSingleton(OBSERVED_FAMILY, OBSERVED_VERSION, () => ({
|
|
218
|
+
pending: new BoundedFifoMap(OBSERVED_PENDING_MAX),
|
|
219
|
+
ledger: new BoundedFifoMap(OBSERVED_LEDGER_MAX),
|
|
220
|
+
handled: new BoundedSet(OBSERVED_HANDLED_MAX),
|
|
221
|
+
turnIndex: -1,
|
|
222
|
+
turnSpentMs: 0,
|
|
223
|
+
sweepCursor: 0,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Session boundary (#2430). Pending baselines are keyed by tool-call id and
|
|
228
|
+
* are unreachable once the session generation advances; the content ledger and
|
|
229
|
+
* the handled set describe a finished session's files. All of it must clear or
|
|
230
|
+
* a resumed session diffs against another session's world.
|
|
231
|
+
*/
|
|
232
|
+
export function resetObservedMutationNet() {
|
|
233
|
+
const current = state();
|
|
234
|
+
current.pending.clear();
|
|
235
|
+
current.ledger.clear();
|
|
236
|
+
current.handled.clear();
|
|
237
|
+
current.turnIndex = -1;
|
|
238
|
+
current.turnSpentMs = 0;
|
|
239
|
+
current.sweepCursor = 0;
|
|
240
|
+
}
|
|
241
|
+
/** Test seam: the net's live state, as plain data. */
|
|
242
|
+
export function _observedMutationStateForTests() {
|
|
243
|
+
const current = state();
|
|
244
|
+
return {
|
|
245
|
+
pending: [...current.pending.keys()],
|
|
246
|
+
ledger: [...current.ledger.keys()],
|
|
247
|
+
handled: [...current.handled],
|
|
248
|
+
turnSpentMs: current.turnSpentMs,
|
|
249
|
+
sweepCursor: current.sweepCursor,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Remember that the normal pipeline already recorded this path this run, so the
|
|
254
|
+
* settled sweep refreshes its baseline instead of reporting the same bytes a
|
|
255
|
+
* second time. Called from the classified `tool_result` path and from
|
|
256
|
+
* `recordMutationThroughSeam`, which is every in-process producer.
|
|
257
|
+
*/
|
|
258
|
+
export function noteMutationHandled(filePath) {
|
|
259
|
+
try {
|
|
260
|
+
const handled = state().handled;
|
|
261
|
+
const key = normalizeMapKey(path.resolve(filePath));
|
|
262
|
+
// `BoundedSet#add` (#2460) owns the FIFO eviction and hands back what it
|
|
263
|
+
// dropped, oldest first — `handled` is membership-only, so there is never
|
|
264
|
+
// more than one entry here, but the array shape keeps this call site
|
|
265
|
+
// identical to every other `BoundedFifoMap`/`BoundedSet` eviction
|
|
266
|
+
// consumer rather than assuming the cardinality.
|
|
267
|
+
const evicted = handled.add(key);
|
|
268
|
+
for (const oldest of evicted) {
|
|
269
|
+
// The drop is NOT silent (#2449 review round 4, S2). Dropping a mark
|
|
270
|
+
// reinstates exactly the defect round 3 (S5) fixed: the ledger still
|
|
271
|
+
// holds the PRE-drain bytes for this file while the only record that
|
|
272
|
+
// those bytes were pi-lens's own has just been thrown away, so the
|
|
273
|
+
// next settled sweep replays our own formatter output as third-party
|
|
274
|
+
// drift. Naming the victim makes that a traceable record rather than a
|
|
275
|
+
// mystery re-format (catalog shape 10).
|
|
276
|
+
emitBounded("observed_handled_evicted",
|
|
277
|
+
// Identity is the DROPPED PATH, not a constant label: the ledger
|
|
278
|
+
// entry is keyed by subject and survives the per-turn cap on the
|
|
279
|
+
// detailed record, so it is what still names WHICH file lost its
|
|
280
|
+
// mark after a turn that overflowed the set many times.
|
|
281
|
+
oldest, {
|
|
282
|
+
filePath: oldest,
|
|
283
|
+
durationMs: 0,
|
|
284
|
+
result: `cap:${OBSERVED_HANDLED_MAX}`,
|
|
285
|
+
}, {
|
|
286
|
+
ledgerKind: "observed-mutation-budget",
|
|
287
|
+
reason: `the handled set is full at ${OBSERVED_HANDLED_MAX}; the oldest pi-lens-authored file lost its mark, so its next drift is reported as third-party`,
|
|
288
|
+
capPerTurn: { limit: 2, turnIndex: state().turnIndex },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// A path that cannot be resolved cannot collide with a ledger key either.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Whether a `tool_result` has a baseline waiting for it.
|
|
298
|
+
*
|
|
299
|
+
* SYNCHRONOUS, and the first thing the `tool_result` seam asks (#2449 review
|
|
300
|
+
* round 2, F1). The previous cut awaited the settle unconditionally, so every
|
|
301
|
+
* tool_result — including the overwhelming majority that miss this map —
|
|
302
|
+
* yielded a microtask before `handleToolResult` reached its debounce and
|
|
303
|
+
* in-flight registration, breaking the #1086 ordering contract.
|
|
304
|
+
*/
|
|
305
|
+
export function hasPendingObservation(toolCallId) {
|
|
306
|
+
return toolCallId !== undefined && state().pending.has(toolCallId);
|
|
307
|
+
}
|
|
308
|
+
function remainingTurnBudgetMs(turnIndex) {
|
|
309
|
+
const current = state();
|
|
310
|
+
if (current.turnIndex !== turnIndex) {
|
|
311
|
+
current.turnIndex = turnIndex;
|
|
312
|
+
current.turnSpentMs = 0;
|
|
313
|
+
}
|
|
314
|
+
return Math.max(0, OBSERVED_TURN_BUDGET_MS - current.turnSpentMs);
|
|
315
|
+
}
|
|
316
|
+
function chargeTurnBudget(turnIndex, spentMs) {
|
|
317
|
+
const current = state();
|
|
318
|
+
if (current.turnIndex !== turnIndex) {
|
|
319
|
+
current.turnIndex = turnIndex;
|
|
320
|
+
current.turnSpentMs = 0;
|
|
321
|
+
}
|
|
322
|
+
current.turnSpentMs += Math.max(0, spentMs);
|
|
323
|
+
}
|
|
324
|
+
/** Test seam: force the per-turn budget to a known state. */
|
|
325
|
+
export function _setObservedTurnBudgetForTests(turnIndex, spentMs) {
|
|
326
|
+
const current = state();
|
|
327
|
+
current.turnIndex = turnIndex;
|
|
328
|
+
current.turnSpentMs = spentMs;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Both bounds on one async step: a wall-clock timeout AND an abort race.
|
|
332
|
+
*
|
|
333
|
+
* #2523 slice 2 folded the hand-rolled timer race out of here and onto
|
|
334
|
+
* `bounded()`. What is left is the ADAPTER: this seam needs a three-way
|
|
335
|
+
* outcome (`timeout` / `aborted` / `failed`) that `bounded()` deliberately
|
|
336
|
+
* does not hand back, because a throw and a blown budget have different
|
|
337
|
+
* remedies and folding them together is catalog shape 10.
|
|
338
|
+
*
|
|
339
|
+
* A loser is DISCARDED, never awaited to completion — the underlying work is
|
|
340
|
+
* stat/read only, so letting it finish unobserved costs nothing, while awaiting
|
|
341
|
+
* it would defeat the bound this exists to enforce.
|
|
342
|
+
*
|
|
343
|
+
* `T extends object` is the load-bearing constraint, not decoration. `bounded()`
|
|
344
|
+
* spells "a bound fired" as a bare `undefined`, so a `work` that could itself
|
|
345
|
+
* resolve `undefined` would be indistinguishable from a timeout here. The first
|
|
346
|
+
* cut paid for that with a `.then((value) => ({ value }))` box — which was
|
|
347
|
+
* VACUOUS: all four call sites return object literals, so no mutation of the box
|
|
348
|
+
* could red a test, and the comment justifying it named `captureLineHashes`,
|
|
349
|
+
* which returns a record and not `undefined` (#2557 review F6). Constraining the
|
|
350
|
+
* type parameter makes the invariant the box was pretending to protect a COMPILE
|
|
351
|
+
* error at any future call site instead of a per-call allocation.
|
|
352
|
+
*/
|
|
353
|
+
async function withBounds(work, timeoutMs, signal, site) {
|
|
354
|
+
// A function, not an inline read: the signal is LIVE, so the compiler's
|
|
355
|
+
// narrowing from the pre-flight check below must not be carried across the
|
|
356
|
+
// await into the post-settle classification (it would fold that branch to
|
|
357
|
+
// "timeout" and hide every mid-await abort).
|
|
358
|
+
const isAborted = () => signal !== undefined && signal.aborted;
|
|
359
|
+
if (isAborted())
|
|
360
|
+
return { ok: false, reason: "aborted" };
|
|
361
|
+
try {
|
|
362
|
+
const settled = await bounded(work(), {
|
|
363
|
+
ms: timeoutMs,
|
|
364
|
+
// The observational net runs on hook paths that may or may not carry
|
|
365
|
+
// a signal; the wall budget is the bound that is always there, and
|
|
366
|
+
// `bounded()` reads a missing signal as one that never aborts.
|
|
367
|
+
signal,
|
|
368
|
+
hook: site.hook,
|
|
369
|
+
label: site.label,
|
|
370
|
+
});
|
|
371
|
+
if (settled !== undefined)
|
|
372
|
+
return { ok: true, value: settled };
|
|
373
|
+
// `bounded()` applies the caller's signal FIRST, so reading it back here
|
|
374
|
+
// reproduces which bound fired without a second channel.
|
|
375
|
+
return { ok: false, reason: isAborted() ? "aborted" : "timeout" };
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// A THROW gets its own reason. Folding it into `timeout` is exactly the
|
|
379
|
+
// misclassification catalog shape 10 warns about: a reader tuning the
|
|
380
|
+
// budget would be chasing a bug that has nothing to do with time.
|
|
381
|
+
return { ok: false, reason: "failed" };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Test-only alias so `tests/clients/hook-await-fold-bounds.test.ts` can pin
|
|
386
|
+
* `withBounds`'s `T extends object` constraint at compile time (#2557 review
|
|
387
|
+
* F-B). `withBounds` itself is module-private -- there is no other way for a
|
|
388
|
+
* test file to reference it in a `@ts-expect-error` probe. Never called
|
|
389
|
+
* outside that probe.
|
|
390
|
+
*/
|
|
391
|
+
export const _withBoundsForTests = withBounds;
|
|
392
|
+
/** `splitLines` semantics from `read-guard.ts`, kept identical on purpose. */
|
|
393
|
+
function splitLines(text) {
|
|
394
|
+
return text.split(/\r?\n/);
|
|
395
|
+
}
|
|
396
|
+
async function captureLineHashes(filePath, budget) {
|
|
397
|
+
try {
|
|
398
|
+
const size = (await fs.promises.stat(filePath)).size;
|
|
399
|
+
if (size > OBSERVED_LINE_HASH_MAX_BYTES)
|
|
400
|
+
return undefined;
|
|
401
|
+
if (budget) {
|
|
402
|
+
if (budget.remainingBytes < size)
|
|
403
|
+
return undefined;
|
|
404
|
+
budget.remainingBytes -= size;
|
|
405
|
+
}
|
|
406
|
+
const lines = splitLines(await fs.promises.readFile(filePath, "utf-8"));
|
|
407
|
+
const hashes = new Map();
|
|
408
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
409
|
+
hashes.set(index + 1, lineContentHash(lines[index] ?? ""));
|
|
410
|
+
}
|
|
411
|
+
return hashes;
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* A baseline's per-line hashes as a dense 1..N array, or `undefined` when it
|
|
419
|
+
* does not cover the whole file.
|
|
420
|
+
*
|
|
421
|
+
* The read-guard stores hashes for the lines a read actually SHOWED, so a
|
|
422
|
+
* windowed read's map covers a slice (say lines 60..100) and says nothing at
|
|
423
|
+
* all about the rest. Treating that as a whole-file baseline is what made the
|
|
424
|
+
* first cut drop a real change at line 3 and report a fabricated 61..101 range
|
|
425
|
+
* (#2449 review round 2, F6). A baseline is usable only when its keys are
|
|
426
|
+
* exactly 1..N.
|
|
427
|
+
*/
|
|
428
|
+
function denseLineBaseline(before) {
|
|
429
|
+
if (before === undefined)
|
|
430
|
+
return undefined;
|
|
431
|
+
const entries = before instanceof Map
|
|
432
|
+
? [...before.entries()]
|
|
433
|
+
: Object.entries(before).map(([line, hash]) => [Number(line), hash]);
|
|
434
|
+
const count = entries.length;
|
|
435
|
+
if (count === 0)
|
|
436
|
+
return undefined;
|
|
437
|
+
const ordered = [];
|
|
438
|
+
ordered.length = count;
|
|
439
|
+
for (const [line, hash] of entries) {
|
|
440
|
+
if (!Number.isInteger(line) || line < 1 || line > count)
|
|
441
|
+
return undefined;
|
|
442
|
+
ordered[line - 1] = hash;
|
|
443
|
+
}
|
|
444
|
+
for (const value of ordered)
|
|
445
|
+
if (value === undefined)
|
|
446
|
+
return undefined;
|
|
447
|
+
return ordered;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Line ranges that actually differ, from the same FNV-1a whitespace-stripped
|
|
451
|
+
* per-line hash the read-guard stores for every read (#505). `before` is the
|
|
452
|
+
* pre-call capture when the net armed one, and otherwise the read-guard's own
|
|
453
|
+
* stored hashes for the file — the issue's "content diff against the
|
|
454
|
+
* read-guard's stored content".
|
|
455
|
+
*
|
|
456
|
+
* Returns `undefined` — meaning "no ranges, over-approximate to the whole
|
|
457
|
+
* file" — whenever a per-line comparison would be a fabrication rather than a
|
|
458
|
+
* measurement:
|
|
459
|
+
*
|
|
460
|
+
* - no baseline at all;
|
|
461
|
+
* - a PARTIAL (windowed) baseline, which knows nothing about the lines outside
|
|
462
|
+
* its window;
|
|
463
|
+
* - a changed LINE COUNT, where every line after an insert or delete shifts and
|
|
464
|
+
* comparing by line number reports the shift instead of the edit;
|
|
465
|
+
* - a file too large to hash, or a spent read budget.
|
|
466
|
+
*
|
|
467
|
+
* Every one of those is the safe direction: the bridge's `resolveChangedRange`
|
|
468
|
+
* then over-approximates to the whole file rather than naming lines that were
|
|
469
|
+
* never touched.
|
|
470
|
+
*/
|
|
471
|
+
export async function deriveObservedEditRanges(filePath, before, budget) {
|
|
472
|
+
const baseline = denseLineBaseline(before);
|
|
473
|
+
if (baseline === undefined)
|
|
474
|
+
return undefined;
|
|
475
|
+
const after = await captureLineHashes(filePath, budget);
|
|
476
|
+
if (after === undefined)
|
|
477
|
+
return undefined;
|
|
478
|
+
if (after.size !== baseline.length)
|
|
479
|
+
return undefined;
|
|
480
|
+
const changed = [];
|
|
481
|
+
for (let line = 1; line <= baseline.length; line += 1) {
|
|
482
|
+
if (after.get(line) !== baseline[line - 1])
|
|
483
|
+
changed.push(line);
|
|
484
|
+
}
|
|
485
|
+
if (changed.length === 0)
|
|
486
|
+
return undefined;
|
|
487
|
+
const ranges = [];
|
|
488
|
+
let start = changed[0];
|
|
489
|
+
let end = changed[0];
|
|
490
|
+
for (const line of changed.slice(1)) {
|
|
491
|
+
if (line === end + 1) {
|
|
492
|
+
end = line;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
ranges.push([start, end]);
|
|
496
|
+
start = line;
|
|
497
|
+
end = line;
|
|
498
|
+
}
|
|
499
|
+
ranges.push([start, end]);
|
|
500
|
+
if (ranges.length > OBSERVED_MAX_EDIT_RANGES) {
|
|
501
|
+
// A rewrite this scattered is a whole-file change in practice; one
|
|
502
|
+
// bounding box keeps the record bounded (AGENTS.md bounded-record rule).
|
|
503
|
+
return [[ranges[0][0], ranges[ranges.length - 1][1]]];
|
|
504
|
+
}
|
|
505
|
+
return ranges;
|
|
506
|
+
}
|
|
507
|
+
function boundingBox(ranges) {
|
|
508
|
+
return [
|
|
509
|
+
Math.min(...ranges.map(([start]) => start)),
|
|
510
|
+
Math.max(...ranges.map(([, end]) => end)),
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Both of these are one-liners over {@link BoundedFifoMap} (#2442, adopted in
|
|
515
|
+
* #2449 review round 4, B2). They stay as named functions because the CAP each
|
|
516
|
+
* map carries is part of this module's contract and the call sites read
|
|
517
|
+
* better naming the map than the container; the eviction block itself is the
|
|
518
|
+
* primitive's.
|
|
519
|
+
*/
|
|
520
|
+
function putPending(key, value) {
|
|
521
|
+
state().pending.set(key, value);
|
|
522
|
+
}
|
|
523
|
+
function putLedger(key, value) {
|
|
524
|
+
state().ledger.set(key, value);
|
|
525
|
+
}
|
|
526
|
+
function seedLedger(snapshot) {
|
|
527
|
+
const seenAtMs = Date.now();
|
|
528
|
+
for (const [key, entry] of snapshot) {
|
|
529
|
+
putLedger(key, {
|
|
530
|
+
hash: entry.hash,
|
|
531
|
+
hashKind: entry.hash === undefined ? undefined : "content",
|
|
532
|
+
size: entry.size,
|
|
533
|
+
mtimeMs: entry.mtimeMs,
|
|
534
|
+
seenAtMs,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function linesDigest(ordered) {
|
|
539
|
+
return createHash("sha256").update(ordered.join("\n")).digest("hex");
|
|
540
|
+
}
|
|
541
|
+
function linesDigestOfContent(content) {
|
|
542
|
+
return linesDigest(splitLines(content).map((line) => lineContentHash(line)));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Take the pre-call baseline for an unclassified tool call.
|
|
546
|
+
*
|
|
547
|
+
* Cost on a call this does NOT arm is one `Map` lookup — the eligibility check
|
|
548
|
+
* runs before any filesystem work, so a classified tool never reaches here at
|
|
549
|
+
* all and a latched tool stops after the lookup.
|
|
550
|
+
*/
|
|
551
|
+
export async function armObservedMutation(args) {
|
|
552
|
+
if (!shouldArmObservationForTool(args.toolName))
|
|
553
|
+
return { armed: false, reason: "not-eligible" };
|
|
554
|
+
if (!args.toolCallId)
|
|
555
|
+
return { armed: false, reason: "no-tool-call-id" };
|
|
556
|
+
const remaining = remainingTurnBudgetMs(args.turnIndex);
|
|
557
|
+
if (remaining <= 0) {
|
|
558
|
+
emitBounded("observed_mutation_budget_exhausted", args.toolName, {
|
|
559
|
+
filePath: args.targetPath,
|
|
560
|
+
durationMs: 0,
|
|
561
|
+
result: `turn-budget:${OBSERVED_TURN_BUDGET_MS}ms`,
|
|
562
|
+
}, {
|
|
563
|
+
ledgerKind: "observed-mutation-budget",
|
|
564
|
+
reason: "per-turn observational snapshot budget exhausted",
|
|
565
|
+
capPerTurn: { limit: 2, turnIndex: args.turnIndex },
|
|
566
|
+
});
|
|
567
|
+
return { armed: false, reason: "budget-exhausted" };
|
|
568
|
+
}
|
|
569
|
+
const started = Date.now();
|
|
570
|
+
const timeoutMs = Math.min(remaining, OBSERVED_CAPTURE_BUDGET_MS);
|
|
571
|
+
const outcome = await withBounds(async () => {
|
|
572
|
+
const universe = await collectObservationUniverse(args.targetPath);
|
|
573
|
+
const captured = await captureFileStatsForPaths(universe.paths, {
|
|
574
|
+
withHashes: true,
|
|
575
|
+
hashBudgetBytes: OBSERVED_HASH_BUDGET_BYTES,
|
|
576
|
+
});
|
|
577
|
+
// INSIDE the bounds, and therefore inside the turn charge below
|
|
578
|
+
// (#2449 review round 4, S3). This reads and per-line-hashes the target
|
|
579
|
+
// up to OBSERVED_LINE_HASH_MAX_BYTES — half a megabyte, and the
|
|
580
|
+
// dominant cost of arming a large target. The first cut awaited it
|
|
581
|
+
// AFTER `chargeTurnBudget` and outside `withBounds`, so the majority of
|
|
582
|
+
// the arm was charged to nobody and covered by neither the timeout nor
|
|
583
|
+
// the abort race — both halves of AGENTS.md's two-bounds rule missing on
|
|
584
|
+
// the single most expensive step.
|
|
585
|
+
const lineHashes = await captureLineHashes(args.targetPath);
|
|
586
|
+
return {
|
|
587
|
+
paths: universe.paths,
|
|
588
|
+
capped: universe.capped,
|
|
589
|
+
stats: captured.snapshot,
|
|
590
|
+
lineHashes,
|
|
591
|
+
};
|
|
592
|
+
}, timeoutMs, args.signal,
|
|
593
|
+
// Reached from `clients/runtime-tool-call.ts`, which #2523's contract
|
|
594
|
+
// gives no wall budget of its own — the ledger key still names it so a
|
|
595
|
+
// blown capture budget is attributable.
|
|
596
|
+
{ hook: "tool_call", label: "armObservedMutation" });
|
|
597
|
+
chargeTurnBudget(args.turnIndex, Date.now() - started);
|
|
598
|
+
if (!outcome.ok) {
|
|
599
|
+
emitBounded("observed_mutation_budget_exhausted", args.toolName, {
|
|
600
|
+
filePath: args.targetPath,
|
|
601
|
+
durationMs: Date.now() - started,
|
|
602
|
+
result: outcome.reason,
|
|
603
|
+
}, {
|
|
604
|
+
ledgerKind: "observed-mutation-budget",
|
|
605
|
+
reason: `observational pre-snapshot ${outcome.reason}`,
|
|
606
|
+
capPerTurn: { limit: 2, turnIndex: args.turnIndex },
|
|
607
|
+
});
|
|
608
|
+
return { armed: false, reason: outcome.reason };
|
|
609
|
+
}
|
|
610
|
+
const targetKey = normalizeMapKey(path.resolve(args.targetPath));
|
|
611
|
+
seedLedger(outcome.value.stats);
|
|
612
|
+
if (outcome.value.capped) {
|
|
613
|
+
// A truncated universe is a real coverage gap and it is named here, at
|
|
614
|
+
// the moment it happens, so it is counted even for a call whose settle
|
|
615
|
+
// never arrives (catalog shape 10).
|
|
616
|
+
emitBounded("observed_target_dir_capped", args.toolName, {
|
|
617
|
+
filePath: args.targetPath,
|
|
618
|
+
durationMs: Date.now() - started,
|
|
619
|
+
result: `entries:${OBSERVED_TARGET_DIR_MAX_ENTRIES}`,
|
|
620
|
+
}, {
|
|
621
|
+
ledgerKind: "observed-mutation-dir-cap",
|
|
622
|
+
reason: `directory target has more than ${OBSERVED_TARGET_DIR_MAX_ENTRIES} entries; the observation covers only the first ${OBSERVED_TARGET_DIR_MAX_ENTRIES}`,
|
|
623
|
+
capPerTurn: { limit: 2, turnIndex: args.turnIndex },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
putPending(args.toolCallId, {
|
|
627
|
+
toolName: args.toolName,
|
|
628
|
+
startedAt: started,
|
|
629
|
+
cwd: args.cwd,
|
|
630
|
+
sessionGeneration: args.sessionGeneration,
|
|
631
|
+
paths: outcome.value.paths,
|
|
632
|
+
stats: outcome.value.stats,
|
|
633
|
+
targetKey,
|
|
634
|
+
targetLineHashes: outcome.value.lineHashes,
|
|
635
|
+
targetDirCapped: outcome.value.capped,
|
|
636
|
+
});
|
|
637
|
+
const durationMs = Date.now() - started;
|
|
638
|
+
logLatency({
|
|
639
|
+
type: "phase",
|
|
640
|
+
toolName: args.toolName,
|
|
641
|
+
phase: "observed_mutation_prescan",
|
|
642
|
+
filePath: args.targetPath,
|
|
643
|
+
durationMs,
|
|
644
|
+
result: `scanned:${outcome.value.stats.size}`,
|
|
645
|
+
});
|
|
646
|
+
return { armed: true, scannedCount: outcome.value.stats.size, durationMs };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The snapshot universe: the TARGET PATH, and nothing else.
|
|
650
|
+
*
|
|
651
|
+
* A file target is itself. A DIRECTORY target is its own entries,
|
|
652
|
+
* non-recursively and capped — a tool naming a directory is claiming that
|
|
653
|
+
* directory as its working set. A path that does not exist yet is still
|
|
654
|
+
* watched as itself, so a call that CREATES it is observed.
|
|
655
|
+
*
|
|
656
|
+
* `capped` is the load-bearing half of the return (#2449 review round 3, S3):
|
|
657
|
+
* when the cap bites, the list below is a TRUNCATION of what the tool named,
|
|
658
|
+
* and the settle must not read an empty diff over it as evidence the tool
|
|
659
|
+
* changed nothing.
|
|
660
|
+
*
|
|
661
|
+
* See the module header for why the sibling walk and the tracked-set fold this
|
|
662
|
+
* replaces were both wrong.
|
|
663
|
+
*/
|
|
664
|
+
async function collectObservationUniverse(targetPath) {
|
|
665
|
+
const target = path.resolve(targetPath);
|
|
666
|
+
try {
|
|
667
|
+
const stat = await fs.promises.stat(target);
|
|
668
|
+
if (stat.isDirectory()) {
|
|
669
|
+
const entries = await fs.promises.readdir(target, {
|
|
670
|
+
withFileTypes: true,
|
|
671
|
+
});
|
|
672
|
+
const files = [];
|
|
673
|
+
let capped = false;
|
|
674
|
+
for (const entry of entries) {
|
|
675
|
+
if (files.length >= OBSERVED_TARGET_DIR_MAX_ENTRIES) {
|
|
676
|
+
capped = true;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
if (entry.isFile())
|
|
680
|
+
files.push(path.join(target, entry.name));
|
|
681
|
+
}
|
|
682
|
+
return { paths: files, capped };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// Does not exist yet, or is unreadable. Watching the path itself is the
|
|
687
|
+
// right answer for both: a call that creates it shows up as an addition.
|
|
688
|
+
}
|
|
689
|
+
return { paths: [target], capped: false };
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Diff the post-call state against the baseline and replay what changed.
|
|
693
|
+
*
|
|
694
|
+
* A change here is the FIRST-CALL coverage #2430's first acceptance criterion
|
|
695
|
+
* asks for: the tool is unknown, so nothing downstream would have recorded the
|
|
696
|
+
* file, and this replay is what puts it in `turn-state.json`.
|
|
697
|
+
*
|
|
698
|
+
* ASYNC since round 3 (T4). Round 2 made it synchronous because
|
|
699
|
+
* `handleToolResult` may not yield before dispatching the pipeline (#1086) —
|
|
700
|
+
* but the fix for that is to keep the PROBE synchronous ({@link
|
|
701
|
+
* hasPendingObservation}, which every call pays and almost every call fails)
|
|
702
|
+
* and to have the caller read what it derives from the post-result bytes
|
|
703
|
+
* BEFORE this yield. Blocking the event loop on a directory's worth of
|
|
704
|
+
* `readFileSync` to buy an ordering property the call site can state directly
|
|
705
|
+
* was the wrong trade. Reaching this function at all already means there is
|
|
706
|
+
* real work to do.
|
|
707
|
+
*
|
|
708
|
+
* NOT budget-gated. The previous cut clamped the post-capture to whatever was
|
|
709
|
+
* left of the per-turn arm budget, which on a busy turn is 1ms — long enough to
|
|
710
|
+
* stat nothing and report a timeout, dropping a mutation that had already been
|
|
711
|
+
* measured (round 2, F5). The baseline exists; re-capturing the target it was
|
|
712
|
+
* taken for is not optional.
|
|
713
|
+
*/
|
|
714
|
+
export async function settleObservedMutation(args) {
|
|
715
|
+
const key = args.toolCallId;
|
|
716
|
+
if (!key)
|
|
717
|
+
return {
|
|
718
|
+
settled: false,
|
|
719
|
+
changedPaths: [],
|
|
720
|
+
replayed: 0,
|
|
721
|
+
scanned: 0,
|
|
722
|
+
stoppedEarly: false,
|
|
723
|
+
reason: "no-tool-call-id",
|
|
724
|
+
};
|
|
725
|
+
const pending = state().pending.get(key);
|
|
726
|
+
if (!pending) {
|
|
727
|
+
// A missing baseline is a real answer, not a no-op: the arm was evicted
|
|
728
|
+
// by the pending cap, or never ran. Saying so keeps "nothing changed"
|
|
729
|
+
// distinguishable from "nothing was watched" (catalog shape 10).
|
|
730
|
+
return {
|
|
731
|
+
settled: false,
|
|
732
|
+
changedPaths: [],
|
|
733
|
+
replayed: 0,
|
|
734
|
+
scanned: 0,
|
|
735
|
+
stoppedEarly: false,
|
|
736
|
+
reason: "no-pending-baseline",
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
state().pending.delete(key);
|
|
740
|
+
if (pending.sessionGeneration !== args.sessionGeneration) {
|
|
741
|
+
// Catalog shape 22: the baseline belongs to a session that has since
|
|
742
|
+
// ended. Diffing across that boundary would attribute another session's
|
|
743
|
+
// world to this call.
|
|
744
|
+
return {
|
|
745
|
+
settled: false,
|
|
746
|
+
changedPaths: [],
|
|
747
|
+
replayed: 0,
|
|
748
|
+
scanned: 0,
|
|
749
|
+
stoppedEarly: false,
|
|
750
|
+
reason: "session-generation-advanced",
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const started = Date.now();
|
|
754
|
+
// The target is `paths[0]` for a file target and the whole (already capped)
|
|
755
|
+
// entry list for a directory one. The deadline can only ever cut a directory
|
|
756
|
+
// target's tail — `captureFileStatsForPaths` always runs its first entry.
|
|
757
|
+
// The outer race exists only to bound a single wedged `stat`, so its budget
|
|
758
|
+
// is deliberately slack compared with the per-entry deadline that does the
|
|
759
|
+
// real work.
|
|
760
|
+
const capture = await withBounds(() => captureFileStatsForPaths(pending.paths, {
|
|
761
|
+
withHashes: true,
|
|
762
|
+
hashBudgetBytes: OBSERVED_HASH_BUDGET_BYTES,
|
|
763
|
+
deadlineMs: started + OBSERVED_SETTLE_DEADLINE_MS,
|
|
764
|
+
signal: args.signal,
|
|
765
|
+
}), OBSERVED_SETTLE_DEADLINE_MS * 4, args.signal, { hook: "tool_result_edit", label: "settleObservedMutation" });
|
|
766
|
+
chargeTurnBudget(args.turnIndex, Date.now() - started);
|
|
767
|
+
if (!capture.ok) {
|
|
768
|
+
// A wedged filesystem call. There is no diff to report and, critically,
|
|
769
|
+
// no evidence the tool was clean — so the clean latch is not advanced.
|
|
770
|
+
noteObservedUnverifiable(args.toolName);
|
|
771
|
+
return {
|
|
772
|
+
settled: false,
|
|
773
|
+
changedPaths: [],
|
|
774
|
+
replayed: 0,
|
|
775
|
+
scanned: 0,
|
|
776
|
+
stoppedEarly: true,
|
|
777
|
+
reason: capture.reason,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
const captured = capture.value;
|
|
781
|
+
seedLedger(captured.snapshot);
|
|
782
|
+
const changed = diffFileStats(pending.stats, captured.snapshot).filter((candidate) => args.isRecordable?.(candidate) !== false);
|
|
783
|
+
// Two different ways the observation can fall short of what the tool named:
|
|
784
|
+
// the ARM already truncated a wide directory (#2449 round 3, S3), or the
|
|
785
|
+
// re-capture's own deadline cut its tail. Both mean the same thing to the
|
|
786
|
+
// verdict below.
|
|
787
|
+
const truncated = pending.targetDirCapped || captured.stoppedEarly;
|
|
788
|
+
const cutReason = pending.targetDirCapped
|
|
789
|
+
? "target-dir-cap-exceeded"
|
|
790
|
+
: captured.stoppedEarly
|
|
791
|
+
? "capture-cut-short"
|
|
792
|
+
: undefined;
|
|
793
|
+
if (changed.length === 0) {
|
|
794
|
+
// An INCOMPLETE observation is not evidence of cleanliness — it is
|
|
795
|
+
// evidence we stopped looking. Advancing the clean latch on it would
|
|
796
|
+
// teach pi-lens to stop watching a tool it never finished watching, and
|
|
797
|
+
// with a directory wider than the cap that is every single call.
|
|
798
|
+
if (truncated)
|
|
799
|
+
noteObservedUnverifiable(args.toolName);
|
|
800
|
+
else
|
|
801
|
+
noteObservedClean(args.toolName);
|
|
802
|
+
return {
|
|
803
|
+
settled: true,
|
|
804
|
+
changedPaths: [],
|
|
805
|
+
replayed: 0,
|
|
806
|
+
scanned: captured.snapshot.size,
|
|
807
|
+
stoppedEarly: truncated,
|
|
808
|
+
reason: cutReason,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const rangeBudget = {
|
|
812
|
+
remainingBytes: OBSERVED_HASH_BUDGET_BYTES,
|
|
813
|
+
};
|
|
814
|
+
let replayed = 0;
|
|
815
|
+
for (const filePath of changed) {
|
|
816
|
+
const baseline = filePath === pending.targetKey
|
|
817
|
+
? pending.targetLineHashes
|
|
818
|
+
: args.getStoredLineHashes?.(filePath);
|
|
819
|
+
const editRanges = await deriveObservedEditRanges(filePath, baseline, rangeBudget);
|
|
820
|
+
const accepted = args.record({
|
|
821
|
+
filePath,
|
|
822
|
+
kind: "edit",
|
|
823
|
+
touchedLines: editRanges ? boundingBox(editRanges) : undefined,
|
|
824
|
+
editRanges: editRanges && editRanges.length > 1 ? editRanges : undefined,
|
|
825
|
+
consumer: args.toolName,
|
|
826
|
+
provenance: "observed",
|
|
827
|
+
});
|
|
828
|
+
if (accepted) {
|
|
829
|
+
replayed += 1;
|
|
830
|
+
noteMutationHandled(filePath);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (replayed > 0) {
|
|
834
|
+
// The universe IS the target path, so a replay here means the tool
|
|
835
|
+
// changed what it named — never a sibling that happened to move
|
|
836
|
+
// underneath it (#2449 round 2, F4).
|
|
837
|
+
const attribution = noteObservedMutation(args.toolName, pending.cwd);
|
|
838
|
+
logLatency({
|
|
839
|
+
type: "phase",
|
|
840
|
+
toolName: args.toolName,
|
|
841
|
+
phase: "observed_mutation_recovered",
|
|
842
|
+
filePath: changed.slice(0, 5).join(","),
|
|
843
|
+
durationMs: Date.now() - started,
|
|
844
|
+
result: `changed:${changed.length} observations:${attribution.observations}${attribution.persisted ? " persisted" : ""}`,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// Every candidate was refused by the recorder (out of scope, or the
|
|
849
|
+
// bookkeeping failed). That is not evidence the tool is clean, so the
|
|
850
|
+
// clean latch is deliberately NOT advanced here.
|
|
851
|
+
logLatency({
|
|
852
|
+
type: "phase",
|
|
853
|
+
toolName: args.toolName,
|
|
854
|
+
phase: "observed_mutation_coverage_unknown",
|
|
855
|
+
filePath: changed.slice(0, 5).join(","),
|
|
856
|
+
durationMs: Date.now() - started,
|
|
857
|
+
result: `refused:${changed.length}`,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
settled: true,
|
|
862
|
+
changedPaths: changed,
|
|
863
|
+
replayed,
|
|
864
|
+
scanned: captured.snapshot.size,
|
|
865
|
+
stoppedEarly: truncated,
|
|
866
|
+
reason: cutReason,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
async function readBytesSafe(filePath) {
|
|
870
|
+
try {
|
|
871
|
+
return await fs.promises.readFile(filePath);
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* One incremental pass over the tracked set (#2449 round 2, F3).
|
|
879
|
+
*
|
|
880
|
+
* The shape that makes this affordable: **stat first, read only on change.**
|
|
881
|
+
* A file whose size and mtime match the ledger costs one `stat` and nothing
|
|
882
|
+
* else, which is the steady state for almost every file on almost every turn.
|
|
883
|
+
* A file whose stat moved is the only one worth reading, and that read is what
|
|
884
|
+
* separates a real edit from a `touch`.
|
|
885
|
+
*
|
|
886
|
+
* A first sighting prefers the read-guard's stored per-line hashes over a file
|
|
887
|
+
* read: #505 already paid for those bytes, so seeding a file the agent has read
|
|
888
|
+
* costs no I/O beyond the stat it already did.
|
|
889
|
+
*
|
|
890
|
+
* `report: false` is the post-drain re-baseline. Its traversal is NOT the
|
|
891
|
+
* tracked set — see `refreshObservedMutationLedger` for why it walks
|
|
892
|
+
* `handled` instead — so `getTrackedPaths` is only ever consulted on the
|
|
893
|
+
* `report: true` (settled-sweep) path and is optional here.
|
|
894
|
+
*/
|
|
895
|
+
async function scanTrackedIncrementally(args, options) {
|
|
896
|
+
const current = state();
|
|
897
|
+
let tracked;
|
|
898
|
+
if (options.report) {
|
|
899
|
+
try {
|
|
900
|
+
tracked = (args.getTrackedPaths?.() ?? []).slice(0, OBSERVED_TRACKED_MAX_FILES);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
tracked = [];
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
// The post-drain refresh's job is to re-baseline pi-lens's OWN drain
|
|
908
|
+
// output, and every file the drain wrote this run is already in
|
|
909
|
+
// `handled` (see `noteMutationHandled`) — so THAT is the traversal, not
|
|
910
|
+
// the full tracked set. See `refreshObservedMutationLedger` for the
|
|
911
|
+
// coverage argument.
|
|
912
|
+
tracked = [...current.handled];
|
|
913
|
+
}
|
|
914
|
+
const total = tracked.length;
|
|
915
|
+
if (total === 0) {
|
|
916
|
+
current.sweepCursor = 0;
|
|
917
|
+
return {
|
|
918
|
+
scanned: 0,
|
|
919
|
+
tracked: 0,
|
|
920
|
+
cursor: 0,
|
|
921
|
+
drifted: [],
|
|
922
|
+
unverifiable: [],
|
|
923
|
+
stoppedEarly: false,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
const startCursor = options.report
|
|
927
|
+
? ((current.sweepCursor % total) + total) % total
|
|
928
|
+
: 0;
|
|
929
|
+
const window = options.report
|
|
930
|
+
? Math.min(OBSERVED_SWEEP_STAT_WINDOW, total)
|
|
931
|
+
: total;
|
|
932
|
+
let hashBytesLeft = OBSERVED_SWEEP_HASH_BUDGET_BYTES;
|
|
933
|
+
const drifted = [];
|
|
934
|
+
const unverifiable = [];
|
|
935
|
+
let scanned = 0;
|
|
936
|
+
let stoppedEarly = false;
|
|
937
|
+
let steps = 0;
|
|
938
|
+
/**
|
|
939
|
+
* Re-baseline one file, and on the POST-DRAIN pass (`report: false`) retire its
|
|
940
|
+
* `handled` mark at the same moment (#2449 review round 4, S2).
|
|
941
|
+
*
|
|
942
|
+
* Per FILE, not all-or-nothing. The mark exists to stop pi-lens's own drain
|
|
943
|
+
* output being read as third-party drift, and it is safe to drop exactly when
|
|
944
|
+
* the ledger has been moved onto the post-drain bytes for THAT file — which is
|
|
945
|
+
* here. The previous cut cleared the whole set after the fact and only when
|
|
946
|
+
* the pass completed, which was wrong in both directions: a parked pass kept
|
|
947
|
+
* marks for files it HAD re-baselined (suppressing a real third-party change
|
|
948
|
+
* to them until some later pass completed), and a completed pass dropped marks
|
|
949
|
+
* for files it had skipped over.
|
|
950
|
+
*/
|
|
951
|
+
const rebaseline = (ledgerKey, entry) => {
|
|
952
|
+
putLedger(ledgerKey, entry);
|
|
953
|
+
if (!options.report)
|
|
954
|
+
current.handled.delete(ledgerKey);
|
|
955
|
+
};
|
|
956
|
+
for (; steps < window; steps += 1) {
|
|
957
|
+
if (args.signal?.aborted === true) {
|
|
958
|
+
stoppedEarly = true;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
if (Date.now() >= options.deadlineMs) {
|
|
962
|
+
stoppedEarly = true;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
const filePath = tracked[(startCursor + steps) % total];
|
|
966
|
+
let key;
|
|
967
|
+
try {
|
|
968
|
+
key = normalizeMapKey(path.resolve(filePath));
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
let stat;
|
|
974
|
+
try {
|
|
975
|
+
stat = await fs.promises.stat(filePath);
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
// Gone, or unreadable. Deletions are not this net's business.
|
|
979
|
+
scanned += 1;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (!stat.isFile()) {
|
|
983
|
+
scanned += 1;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
scanned += 1;
|
|
987
|
+
const previous = current.ledger.get(key);
|
|
988
|
+
const next = {
|
|
989
|
+
size: stat.size,
|
|
990
|
+
mtimeMs: stat.mtimeMs,
|
|
991
|
+
hash: previous?.hash,
|
|
992
|
+
hashKind: previous?.hashKind,
|
|
993
|
+
seenAtMs: Date.now(),
|
|
994
|
+
};
|
|
995
|
+
if (previous === undefined) {
|
|
996
|
+
const dense = denseLineBaseline(args.getStoredLineHashes?.(key));
|
|
997
|
+
if (dense) {
|
|
998
|
+
next.hash = linesDigest(dense);
|
|
999
|
+
next.hashKind = "lines";
|
|
1000
|
+
}
|
|
1001
|
+
else if (stat.size <= hashBytesLeft) {
|
|
1002
|
+
const content = await readBytesSafe(filePath);
|
|
1003
|
+
if (content !== undefined) {
|
|
1004
|
+
hashBytesLeft -= stat.size;
|
|
1005
|
+
next.hash = createHash("sha256").update(content).digest("hex");
|
|
1006
|
+
next.hashKind = "content";
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
rebaseline(key, next);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
// The stat short-circuit — the reason this pass is affordable — but only
|
|
1013
|
+
// when the baseline is old enough to be trusted. See `LedgerEntry.seenAtMs`
|
|
1014
|
+
// for the same-tick same-size rewrite it would otherwise bake in forever.
|
|
1015
|
+
//
|
|
1016
|
+
// The reference instant is OBSERVED_LEDGER_SETTLE_MS *before* the entry
|
|
1017
|
+
// was recorded: a file whose mtime is later than that was still in
|
|
1018
|
+
// flight when we looked at it, so its stat cannot stand in for its
|
|
1019
|
+
// bytes. `fresh` therefore means "already settled when recorded".
|
|
1020
|
+
const baselineSettled = freshnessFromMtime({
|
|
1021
|
+
mtimeMs: stat.mtimeMs,
|
|
1022
|
+
referenceMs: previous.seenAtMs - OBSERVED_LEDGER_SETTLE_MS,
|
|
1023
|
+
toleranceMs: 0,
|
|
1024
|
+
}).verdict === "fresh";
|
|
1025
|
+
if (previous.size === stat.size &&
|
|
1026
|
+
previous.mtimeMs === stat.mtimeMs &&
|
|
1027
|
+
baselineSettled) {
|
|
1028
|
+
rebaseline(key, next);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
// A drift CANDIDATE. Size moving is proof on its own; mtime moving is
|
|
1032
|
+
// NOT (a `touch` bumps it without a byte moving), so mtime-only drift
|
|
1033
|
+
// has to be confirmed against content before anything is replayed
|
|
1034
|
+
// (#2449 round 2, F7).
|
|
1035
|
+
const sizeChanged = previous.size !== stat.size;
|
|
1036
|
+
let verdict = sizeChanged ? "drift" : "unverifiable";
|
|
1037
|
+
if (stat.size <= hashBytesLeft) {
|
|
1038
|
+
const content = await readBytesSafe(filePath);
|
|
1039
|
+
if (content !== undefined) {
|
|
1040
|
+
hashBytesLeft -= stat.size;
|
|
1041
|
+
const contentHash = createHash("sha256").update(content).digest("hex");
|
|
1042
|
+
if (previous.hash === undefined) {
|
|
1043
|
+
verdict = sizeChanged ? "drift" : "unverifiable";
|
|
1044
|
+
}
|
|
1045
|
+
else if (previous.hashKind === "lines") {
|
|
1046
|
+
verdict =
|
|
1047
|
+
linesDigestOfContent(content.toString("utf-8")) !== previous.hash
|
|
1048
|
+
? "drift"
|
|
1049
|
+
: "clean";
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
verdict = contentHash !== previous.hash ? "drift" : "clean";
|
|
1053
|
+
}
|
|
1054
|
+
// Store the CONTENT hash either way, so the next change to this
|
|
1055
|
+
// file is verifiable even if this one was not.
|
|
1056
|
+
next.hash = contentHash;
|
|
1057
|
+
next.hashKind = "content";
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
rebaseline(key, next);
|
|
1061
|
+
if (!options.report)
|
|
1062
|
+
continue;
|
|
1063
|
+
if (current.handled.has(key))
|
|
1064
|
+
continue;
|
|
1065
|
+
if (args.isRecordable?.(key) === false)
|
|
1066
|
+
continue;
|
|
1067
|
+
if (verdict === "drift")
|
|
1068
|
+
drifted.push(key);
|
|
1069
|
+
else if (verdict === "unverifiable")
|
|
1070
|
+
unverifiable.push(key);
|
|
1071
|
+
}
|
|
1072
|
+
if (options.report)
|
|
1073
|
+
current.sweepCursor = (startCursor + steps) % total;
|
|
1074
|
+
return {
|
|
1075
|
+
scanned,
|
|
1076
|
+
tracked: total,
|
|
1077
|
+
cursor: options.report ? current.sweepCursor : 0,
|
|
1078
|
+
drifted,
|
|
1079
|
+
unverifiable,
|
|
1080
|
+
stoppedEarly,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* The turn-boundary net (#2430 item 3), run at `agent_settled` BEFORE the
|
|
1085
|
+
* deferred drain so anything it finds is formatted in the same settle.
|
|
1086
|
+
*
|
|
1087
|
+
* Files the pipeline already recorded this run refresh their baseline and are
|
|
1088
|
+
* never reported twice. Coverage is incremental and self-reported: see
|
|
1089
|
+
* {@link scanTrackedIncrementally} and {@link SettledSweepResult}.
|
|
1090
|
+
*/
|
|
1091
|
+
export async function runObservedSettledSweep(args) {
|
|
1092
|
+
const started = Date.now();
|
|
1093
|
+
const outcome = await withBounds(() => scanTrackedIncrementally(args, {
|
|
1094
|
+
report: true,
|
|
1095
|
+
deadlineMs: started + OBSERVED_CAPTURE_BUDGET_MS,
|
|
1096
|
+
}),
|
|
1097
|
+
// The inner loop parks its own cursor at the deadline, so this outer
|
|
1098
|
+
// race exists only to bound a single wedged `stat` — hence the slack.
|
|
1099
|
+
OBSERVED_CAPTURE_BUDGET_MS * 2, args.signal, { hook: "agent_settled", label: "runObservedSettledSweep" });
|
|
1100
|
+
if (!outcome.ok) {
|
|
1101
|
+
emitBounded("observed_sweep_skipped_budget", "settled-sweep", { durationMs: Date.now() - started, result: outcome.reason }, {
|
|
1102
|
+
ledgerKind: "observed-mutation-budget",
|
|
1103
|
+
reason: `settled sweep ${outcome.reason}`,
|
|
1104
|
+
capPerTurn: { limit: 2, turnIndex: args.turnIndex },
|
|
1105
|
+
});
|
|
1106
|
+
return {
|
|
1107
|
+
scanned: 0,
|
|
1108
|
+
notReachedThisPass: 0,
|
|
1109
|
+
cursor: state().sweepCursor,
|
|
1110
|
+
drifted: [],
|
|
1111
|
+
unverifiable: [],
|
|
1112
|
+
replayed: 0,
|
|
1113
|
+
reason: outcome.reason,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const scan = outcome.value;
|
|
1117
|
+
const rangeBudget = {
|
|
1118
|
+
remainingBytes: OBSERVED_SWEEP_RANGE_BUDGET_BYTES,
|
|
1119
|
+
};
|
|
1120
|
+
let replayed = 0;
|
|
1121
|
+
for (const filePath of scan.drifted) {
|
|
1122
|
+
if (args.signal?.aborted === true)
|
|
1123
|
+
break;
|
|
1124
|
+
const editRanges = await deriveObservedEditRanges(filePath, args.getStoredLineHashes?.(filePath), rangeBudget);
|
|
1125
|
+
const accepted = args.record({
|
|
1126
|
+
filePath,
|
|
1127
|
+
kind: "edit",
|
|
1128
|
+
touchedLines: editRanges ? boundingBox(editRanges) : undefined,
|
|
1129
|
+
editRanges: editRanges && editRanges.length > 1 ? editRanges : undefined,
|
|
1130
|
+
consumer: "settled-sweep",
|
|
1131
|
+
provenance: "settled-sweep",
|
|
1132
|
+
});
|
|
1133
|
+
if (accepted)
|
|
1134
|
+
replayed += 1;
|
|
1135
|
+
}
|
|
1136
|
+
const notReachedThisPass = Math.max(0, scan.tracked - scan.scanned);
|
|
1137
|
+
if (scan.drifted.length > 0 || scan.unverifiable.length > 0) {
|
|
1138
|
+
logLatency({
|
|
1139
|
+
type: "phase",
|
|
1140
|
+
phase: "observed_settled_sweep_drift",
|
|
1141
|
+
filePath: scan.drifted.slice(0, 5).join(","),
|
|
1142
|
+
durationMs: Date.now() - started,
|
|
1143
|
+
result: `drifted:${scan.drifted.length} replayed:${replayed} unverifiable:${scan.unverifiable.length} scanned:${scan.scanned}/${scan.tracked} cursor:${scan.cursor}`,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
if (scan.unverifiable.length > 0) {
|
|
1147
|
+
// Named, capped, and never replayed: a stat that moved without a
|
|
1148
|
+
// provable content change is a gap in coverage, and a gap that reports
|
|
1149
|
+
// itself is the only kind that gets fixed (catalog shape 10).
|
|
1150
|
+
emitBounded("observed_sweep_unverifiable", "settled-sweep", {
|
|
1151
|
+
durationMs: Date.now() - started,
|
|
1152
|
+
result: `unverifiable:${scan.unverifiable.length}`,
|
|
1153
|
+
filePath: scan.unverifiable.slice(0, 5).join(","),
|
|
1154
|
+
}, {
|
|
1155
|
+
ledgerKind: "observed-mutation-budget",
|
|
1156
|
+
reason: "tracked file's size/mtime moved but no hashed baseline could confirm a content change",
|
|
1157
|
+
capPerTurn: { limit: 2, turnIndex: args.turnIndex },
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
scanned: scan.scanned,
|
|
1162
|
+
notReachedThisPass,
|
|
1163
|
+
cursor: scan.cursor,
|
|
1164
|
+
drifted: scan.drifted,
|
|
1165
|
+
unverifiable: scan.unverifiable,
|
|
1166
|
+
replayed,
|
|
1167
|
+
reason: scan.stoppedEarly ? "window-parked" : undefined,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Re-baseline pi-lens's OWN writes AFTER the deferred drain.
|
|
1172
|
+
*
|
|
1173
|
+
* The drain is pi-lens formatting and autofixing files it already knows about,
|
|
1174
|
+
* so those bytes are ours. Without this the next settle would read them as
|
|
1175
|
+
* third-party drift and requeue the same files forever.
|
|
1176
|
+
*
|
|
1177
|
+
* ## The traversal is `handled`, not the tracked set (#2449 review round 5, F2)
|
|
1178
|
+
*
|
|
1179
|
+
* Every file this function needs to re-baseline is, by construction, already
|
|
1180
|
+
* in `handled`: that set exists exactly to name "pi-lens wrote these bytes
|
|
1181
|
+
* this run" (see `noteMutationHandled`), and this function's only job is to
|
|
1182
|
+
* clear that claim once the ledger agrees. Walking `getTrackedPaths()`
|
|
1183
|
+
* instead — the earlier shape — coupled this function's cost and completion
|
|
1184
|
+
* to the size of the TRACKED set (up to `OBSERVED_TRACKED_MAX_FILES`, i.e.
|
|
1185
|
+
* 400) rather than to the size of the DRAIN (a handful of files), and on a
|
|
1186
|
+
* tracked set too large to finish inside `OBSERVED_CAPTURE_BUDGET_MS` the
|
|
1187
|
+
* pass parked at the same prefix every turn — `report: false` always starts
|
|
1188
|
+
* its cursor at 0 — so files in the tail never got re-baselined and their
|
|
1189
|
+
* `handled` marks never retired, permanently suppressing drift reports for
|
|
1190
|
+
* them (reviewer PROBE-B1).
|
|
1191
|
+
*
|
|
1192
|
+
* `handled` is bounded by `OBSERVED_HANDLED_MAX` and holds only files the
|
|
1193
|
+
* pipeline or the drain actually wrote this run, so iterating it is
|
|
1194
|
+
* O(handful) rather than O(tracked set) and — barring an aborted turn or a
|
|
1195
|
+
* genuinely pathological handled-set size — completes in one pass every time.
|
|
1196
|
+
* That is what lets every mark retire on every refresh instead of only the
|
|
1197
|
+
* marks a rotating cursor happened to reach.
|
|
1198
|
+
*
|
|
1199
|
+
* ## The retirement is still per FILE (#2449 review rounds 3 (S5) and 4 (S2))
|
|
1200
|
+
*
|
|
1201
|
+
* A mark is dropped exactly when the ledger has been moved onto the
|
|
1202
|
+
* POST-drain bytes for that same file — inside the scan, one file at a time
|
|
1203
|
+
* (see `rebaseline`) — rather than all-or-nothing on completion. An aborted
|
|
1204
|
+
* or truncated pass therefore still retires the marks for the files it DID
|
|
1205
|
+
* reach and correctly leaves the rest standing; it no longer needs a
|
|
1206
|
+
* dedicated "coverage gap" record to stay honest, because the traversal it
|
|
1207
|
+
* covers is the whole of what it was asked to do.
|
|
1208
|
+
*/
|
|
1209
|
+
export async function refreshObservedMutationLedger(args) {
|
|
1210
|
+
const outcome = await withBounds(() => scanTrackedIncrementally(args, {
|
|
1211
|
+
report: false,
|
|
1212
|
+
deadlineMs: Date.now() + OBSERVED_CAPTURE_BUDGET_MS,
|
|
1213
|
+
}), OBSERVED_CAPTURE_BUDGET_MS * 2, args.signal, { hook: "agent_settled", label: "refreshObservedMutationLedger" });
|
|
1214
|
+
return outcome.ok ? outcome.value.scanned : 0;
|
|
1215
|
+
}
|