@1aboveio/skills 0.10.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/LICENSE +3 -0
- package/README.md +90 -0
- package/bin/1aboveio-skills.mjs +18 -0
- package/package.json +28 -0
- package/runtime/skills/distribution/generated/recipes.json +1189 -0
- package/runtime/skills/distribution/scripts/bundles.mjs +280 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/main-module.mjs +80 -0
- package/skills/backend/airflow-dag-develop/LICENSE +3 -0
- package/skills/backend/airflow-dag-develop/SKILL.md +111 -0
- package/skills/backend/app-debug/LICENSE +3 -0
- package/skills/backend/app-debug/SKILL.md +109 -0
- package/skills/backend/app-debug/references/common-errors.md +128 -0
- package/skills/backend/python-backend/LICENSE +3 -0
- package/skills/backend/python-backend/SKILL.md +326 -0
- package/skills/cicd-pipeline/cloud-build/LICENSE +3 -0
- package/skills/cicd-pipeline/cloud-build/SKILL.md +707 -0
- package/skills/cicd-pipeline/cloud-debug/LICENSE +3 -0
- package/skills/cicd-pipeline/cloud-debug/SKILL.md +316 -0
- package/skills/cicd-pipeline/cloud-debug/references/build-failures.md +238 -0
- package/skills/cicd-pipeline/cloud-debug/references/deploy-failures.md +376 -0
- package/skills/cicd-pipeline/cloud-debug/references/pipeline-failures.md +378 -0
- package/skills/cicd-pipeline/cloud-deploy/LICENSE +3 -0
- package/skills/cicd-pipeline/cloud-deploy/SKILL.md +229 -0
- package/skills/cicd-pipeline/cloud-deploy/references/config-templates.md +257 -0
- package/skills/cicd-pipeline/docker/LICENSE +3 -0
- package/skills/cicd-pipeline/docker/SKILL.md +126 -0
- package/skills/cicd-pipeline/google-cloud/LICENSE +3 -0
- package/skills/cicd-pipeline/google-cloud/SKILL.md +118 -0
- package/skills/cicd-pipeline/google-cloud/references/gcs.md +469 -0
- package/skills/cicd-pipeline/google-cloud/references/iam.md +451 -0
- package/skills/cicd-pipeline/google-cloud/references/project.md +349 -0
- package/skills/cicd-pipeline/google-cloud/references/secrets.md +336 -0
- package/skills/cicd-pipeline/google-cloud/references/vpc.md +312 -0
- package/skills/cicd-pipeline/google-cloud/scripts/create-sa.sh +36 -0
- package/skills/cicd-pipeline/google-cloud/scripts/gcp-config.sh +31 -0
- package/skills/cicd-pipeline/google-cloud/scripts/grant-iap.sh +41 -0
- package/skills/cicd-pipeline/google-cloud/scripts/setup-secrets.sh +48 -0
- package/skills/cicd-pipeline/mergify/LICENSE +3 -0
- package/skills/cicd-pipeline/mergify/SKILL.md +138 -0
- package/skills/cicd-pipeline/mergify/assets/templates/mergify.yml +237 -0
- package/skills/cicd-pipeline/mergify/assets/templates/ruleset.json +46 -0
- package/skills/cicd-pipeline/mergify/references/branch-protection.md +277 -0
- package/skills/cicd-pipeline/mergify/references/configuration.md +183 -0
- package/skills/cicd-pipeline/mergify/references/diagnosis.md +73 -0
- package/skills/cicd-pipeline/mergify/references/traps.md +78 -0
- package/skills/cicd-pipeline/mergify/references/watch-contract.md +218 -0
- package/skills/cicd-pipeline/mergify/scripts/audit-core.mjs +131 -0
- package/skills/cicd-pipeline/mergify/scripts/audit.mjs +4 -0
- package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery-core.mjs +663 -0
- package/skills/cicd-pipeline/mergify/scripts/watch-pr-delivery.mjs +4 -0
- package/skills/cicd-pipeline/podman/LICENSE +3 -0
- package/skills/cicd-pipeline/podman/SKILL.md +70 -0
- package/skills/cicd-pipeline/podman/agents/openai.yaml +4 -0
- package/skills/cicd-pipeline/podman/assets/templates/podman-compose-socket-directory.yml +6 -0
- package/skills/cicd-pipeline/podman/assets/templates/podman-service-override.conf +3 -0
- package/skills/cicd-pipeline/podman/references/compose-compatibility.md +70 -0
- package/skills/cicd-pipeline/podman/references/networking-and-ports.md +74 -0
- package/skills/cicd-pipeline/podman/references/rootless-services-and-sockets.md +156 -0
- package/skills/cicd-pipeline/podman/references/troubleshooting.md +98 -0
- package/skills/engineering/e2e-test/LICENSE +3 -0
- package/skills/engineering/e2e-test/SKILL.md +156 -0
- package/skills/engineering/e2e-test/assets/ci-gates.cloudbuild.yaml +272 -0
- package/skills/engineering/e2e-test/assets/ci-gates.github.yml +451 -0
- package/skills/engineering/e2e-test/assets/e2e-workflow.yml +282 -0
- package/skills/engineering/e2e-test/references/authoring/auth-flows.md +159 -0
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +71 -0
- package/skills/engineering/e2e-test/references/authoring/playwright-patterns.md +219 -0
- package/skills/engineering/e2e-test/references/authoring/test-skipping.md +44 -0
- package/skills/engineering/e2e-test/references/ci-integration.md +121 -0
- package/skills/engineering/e2e-test/references/ci-playwright-container.md +280 -0
- package/skills/engineering/e2e-test/references/debugging.md +36 -0
- package/skills/engineering/e2e-test/references/presentation-sweep.md +131 -0
- package/skills/engineering/e2e-test/references/reviewing.md +39 -0
- package/skills/engineering/e2e-test/references/route-discovery.md +50 -0
- package/skills/engineering/e2e-test/references/route-manifest.md +44 -0
- package/skills/engineering/e2e-test/scripts/detect-routes-fastapi.py +290 -0
- package/skills/engineering/e2e-test/scripts/detect-routes-nextjs.mjs +200 -0
- package/skills/engineering/e2e-test/scripts/post-visual-evidence.mjs +158 -0
- package/skills/engineering/e2e-test/scripts/presentation-checks.mjs +171 -0
- package/skills/engineering/e2e-test/scripts/presentation-perceivability.mjs +179 -0
- package/skills/engineering/e2e-test/scripts/presentation-reachability.mjs +154 -0
- package/skills/engineering/e2e-test/scripts/presentation-render-health.mjs +141 -0
- package/skills/engineering/e2e-test/scripts/presentation-sweep.mjs +148 -0
- package/skills/engineering/e2e-test/scripts/presentation-temporal.mjs +127 -0
- package/skills/engineering/e2e-test/scripts/presentation-visual.mjs +84 -0
- package/skills/engineering/e2e-test/scripts/project-route-manifest.mjs +75 -0
- package/skills/engineering/e2e-test/scripts/validate-manifest.mjs +106 -0
- package/skills/engineering/engineering-runtime/LICENSE +3 -0
- package/skills/engineering/engineering-runtime/SKILL.md +10 -0
- package/skills/engineering/engineering-runtime/agents/openai.yaml +6 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +553 -0
- package/skills/engineering/engineering-runtime/scripts/exact-head-artifact.mjs +131 -0
- package/skills/engineering/engineering-runtime/scripts/head-check-set.mjs +398 -0
- package/skills/engineering/engineering-runtime/scripts/main-module.mjs +80 -0
- package/skills/engineering/engineering-runtime/scripts/mergify-yaml.mjs +11 -0
- package/skills/engineering/engineering-runtime/scripts/package-lock.json +43 -0
- package/skills/engineering/engineering-runtime/scripts/package.json +10 -0
- package/skills/engineering/engineering-runtime/scripts/required-check-plan.mjs +223 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +576 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +166 -0
- package/skills/engineering/ensure-coverage/LICENSE +3 -0
- package/skills/engineering/ensure-coverage/SKILL.md +136 -0
- package/skills/engineering/ensure-coverage/evals/evals.json +125 -0
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +91 -0
- package/skills/engineering/ensure-coverage/references/breadth/inventory-contract.md +83 -0
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +44 -0
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +16 -0
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +68 -0
- package/skills/engineering/ensure-coverage/references/depth/coverage.config.example.json +25 -0
- package/skills/engineering/ensure-coverage/references/depth/grading.md +35 -0
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +87 -0
- package/skills/engineering/ensure-coverage/references/depth/test-smells.md +23 -0
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +164 -0
- package/skills/engineering/ensure-coverage/references/enforcement/hooks.md +85 -0
- package/skills/engineering/ensure-coverage/references/examples/coverage-ledger.md +109 -0
- package/skills/engineering/ensure-coverage/references/examples/refund-flow.md +33 -0
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +78 -0
- package/skills/engineering/ensure-coverage/references/presentation/runner-contract.md +74 -0
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +33 -0
- package/skills/engineering/ensure-coverage/references/process/output-template.md +139 -0
- package/skills/engineering/ensure-coverage/references/process/review-contract-template.md +119 -0
- package/skills/engineering/ensure-coverage/references/process/scope-class.md +178 -0
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +55 -0
- package/skills/engineering/ensure-coverage/schemas/coverage-config.schema.json +45 -0
- package/skills/engineering/ensure-coverage/schemas/coverage-file.schema.json +93 -0
- package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.mjs +178 -0
- package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.mjs +101 -0
- package/skills/engineering/ensure-coverage/scripts/ci-audit.mjs +358 -0
- package/skills/engineering/ensure-coverage/scripts/coverage-checklist.mjs +494 -0
- package/skills/engineering/ensure-coverage/scripts/coverage-ledger.mjs +663 -0
- package/skills/engineering/ensure-coverage/scripts/design-parity.mjs +591 -0
- package/skills/engineering/ensure-coverage/scripts/evidence-block.mjs +367 -0
- package/skills/engineering/ensure-coverage/scripts/lint-tests.mjs +269 -0
- package/skills/engineering/ensure-coverage/scripts/mock-policy-config.mjs +176 -0
- package/skills/engineering/ensure-coverage/scripts/package-lock.json +76 -0
- package/skills/engineering/ensure-coverage/scripts/package.json +19 -0
- package/skills/engineering/ensure-coverage/scripts/scope-class.mjs +554 -0
- package/skills/engineering/harness-runtime/LICENSE +3 -0
- package/skills/engineering/harness-runtime/SKILL.md +18 -0
- package/skills/engineering/harness-runtime/agents/openai.yaml +6 -0
- package/skills/engineering/harness-runtime/bin/discover-models.mjs +4 -0
- package/skills/engineering/harness-runtime/bin/model-catalog.mjs +4 -0
- package/skills/engineering/harness-runtime/contracts.md +15 -0
- package/skills/engineering/harness-runtime/discover-models.mjs +392 -0
- package/skills/engineering/harness-runtime/fixtures/native-question-schemas.json +33 -0
- package/skills/engineering/harness-runtime/fixtures/question-responses.json +54 -0
- package/skills/engineering/harness-runtime/index.mjs +767 -0
- package/skills/engineering/harness-runtime/model-catalog.mjs +787 -0
- package/skills/engineering/harness-runtime/native-question-contracts.md +37 -0
- package/skills/engineering/harness-runtime/references/model-catalog-seed.json +159 -0
- package/skills/engineering/harness-runtime/references/model-catalog.md +57 -0
- package/skills/engineering/implement-and-pr/LICENSE +3 -0
- package/skills/engineering/implement-and-pr/SKILL.md +176 -0
- package/skills/engineering/implement-and-pr/references/ci-iteration.md +10 -0
- package/skills/engineering/implement-and-pr/references/closeout.md +27 -0
- package/skills/engineering/implement-and-pr/references/contract-complete-fix-rounds.md +34 -0
- package/skills/engineering/implement-and-pr/references/evidence-rules.md +39 -0
- package/skills/engineering/implement-and-pr/references/incremental-plan.md +16 -0
- package/skills/engineering/implement-and-pr/references/self-review.md +23 -0
- package/skills/engineering/implement-and-pr/references/tdd-mode.md +18 -0
- package/skills/engineering/resolve-issues/LICENSE +3 -0
- package/skills/engineering/resolve-issues/SKILL.md +167 -0
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +448 -0
- package/skills/engineering/resolve-issues/references/breaker.md +82 -0
- package/skills/engineering/resolve-issues/references/deliverables.md +27 -0
- package/skills/engineering/resolve-issues/references/delivery.md +108 -0
- package/skills/engineering/resolve-issues/references/evidence-lane.md +21 -0
- package/skills/engineering/resolve-issues/references/exact-head-ci.md +287 -0
- package/skills/engineering/resolve-issues/references/fan-out.md +33 -0
- package/skills/engineering/resolve-issues/references/finalization.md +68 -0
- package/skills/engineering/resolve-issues/references/guarantees.md +10 -0
- package/skills/engineering/resolve-issues/references/high-risk.md +29 -0
- package/skills/engineering/resolve-issues/references/incidents/848/README.md +156 -0
- package/skills/engineering/resolve-issues/references/intake.md +86 -0
- package/skills/engineering/resolve-issues/references/integration-gate.md +53 -0
- package/skills/engineering/resolve-issues/references/interference.md +87 -0
- package/skills/engineering/resolve-issues/references/loop.md +134 -0
- package/skills/engineering/resolve-issues/references/model-catalog.md +9 -0
- package/skills/engineering/resolve-issues/references/postmortem.md +27 -0
- package/skills/engineering/resolve-issues/references/pre-flight-model-slots.md +41 -0
- package/skills/engineering/resolve-issues/references/pre-flight-recording-and-checkout.md +48 -0
- package/skills/engineering/resolve-issues/references/pre-flight.md +41 -0
- package/skills/engineering/resolve-issues/references/regression-checklist.md +26 -0
- package/skills/engineering/resolve-issues/references/run-state.md +288 -0
- package/skills/engineering/resolve-issues/references/sandboxed-testing.md +48 -0
- package/skills/engineering/resolve-issues/references/spawn-contract.md +96 -0
- package/skills/engineering/resolve-issues/references/terminal-evidence-journal.md +40 -0
- package/skills/engineering/resolve-issues/references/why.md +653 -0
- package/skills/engineering/resolve-issues/schemas/fix-round.schema.json +49 -0
- package/skills/engineering/resolve-issues/scripts/combine-and-verify.mjs +721 -0
- package/skills/engineering/resolve-issues/scripts/component-candidate.mjs +962 -0
- package/skills/engineering/resolve-issues/scripts/contract-revision.mjs +220 -0
- package/skills/engineering/resolve-issues/scripts/detect-delivery-mode.mjs +420 -0
- package/skills/engineering/resolve-issues/scripts/detect-target-branch.mjs +256 -0
- package/skills/engineering/resolve-issues/scripts/detect-workspace-mode.mjs +168 -0
- package/skills/engineering/resolve-issues/scripts/discover-models.mjs +9 -0
- package/skills/engineering/resolve-issues/scripts/doctrine.mjs +62 -0
- package/skills/engineering/resolve-issues/scripts/evidence-lifecycle-contract.mjs +191 -0
- package/skills/engineering/resolve-issues/scripts/exact-head-ci.mjs +413 -0
- package/skills/engineering/resolve-issues/scripts/exact-head-github-provider.mjs +332 -0
- package/skills/engineering/resolve-issues/scripts/finalize.mjs +488 -0
- package/skills/engineering/resolve-issues/scripts/fix-rounds.mjs +3307 -0
- package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-circular-1001.json +16 -0
- package/skills/engineering/resolve-issues/scripts/fixtures/evidence-lifecycle-valid-sequencing.json +51 -0
- package/skills/engineering/resolve-issues/scripts/fixtures/fmm-express-830-component-candidate.json +17 -0
- package/skills/engineering/resolve-issues/scripts/fixtures/head-check-set-1081.json +166 -0
- package/skills/engineering/resolve-issues/scripts/gate-value-series.mjs +92 -0
- package/skills/engineering/resolve-issues/scripts/guide-index.mjs +73 -0
- package/skills/engineering/resolve-issues/scripts/head-check-set.mjs +159 -0
- package/skills/engineering/resolve-issues/scripts/interference.mjs +427 -0
- package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +9 -0
- package/skills/engineering/resolve-issues/scripts/next-operations.mjs +419 -0
- package/skills/engineering/resolve-issues/scripts/postmortem.mjs +909 -0
- package/skills/engineering/resolve-issues/scripts/preflight-questions.mjs +322 -0
- package/skills/engineering/resolve-issues/scripts/reconcile-contained-unit-prs.mjs +415 -0
- package/skills/engineering/resolve-issues/scripts/release-state-contract.mjs +697 -0
- package/skills/engineering/resolve-issues/scripts/report.mjs +494 -0
- package/skills/engineering/resolve-issues/scripts/required-check-plan.mjs +172 -0
- package/skills/engineering/resolve-issues/scripts/round-metadata.mjs +79 -0
- package/skills/engineering/resolve-issues/scripts/run-state-review6-cases.mjs +334 -0
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +4784 -0
- package/skills/engineering/resolve-issues/scripts/sandbox-selftest.mjs +395 -0
- package/skills/engineering/resolve-issues/scripts/spawn-contract.mjs +290 -0
- package/skills/engineering/resolve-issues/scripts/terminal-dispositions.mjs +170 -0
- package/skills/engineering/resolve-issues/scripts/terminal-evidence-journal.mjs +293 -0
- package/skills/engineering/resolve-issues/scripts/unit-kind.mjs +197 -0
- package/skills/engineering/resolve-issues/scripts/unit-lifecycle.mjs +127 -0
- package/skills/engineering/resolve-issues/scripts/watch-delivery.mjs +893 -0
- package/skills/engineering/resolve-issues/scripts/workspaces.mjs +829 -0
- package/skills/engineering/resolve-issues/workflows/independent-review.workflow.js +290 -0
- package/skills/engineering/resolve-issues/workflows/prior-art-scan.workflow.js +80 -0
- package/skills/engineering/resolve-issues/workflows/workflow-smoke.mjs +102 -0
- package/skills/engineering/resolve-release/LICENSE +3 -0
- package/skills/engineering/resolve-release/SKILL.md +112 -0
- package/skills/engineering/resolve-release/references/assembly.md +137 -0
- package/skills/engineering/resolve-release/references/auto-when-green.md +56 -0
- package/skills/engineering/resolve-release/references/candidate.md +167 -0
- package/skills/engineering/resolve-release/references/exposure.md +178 -0
- package/skills/engineering/resolve-release/references/handoff.md +24 -0
- package/skills/engineering/resolve-release/references/postmortem.md +230 -0
- package/skills/engineering/resolve-release/references/preflight.md +207 -0
- package/skills/engineering/resolve-release/references/principles.md +94 -0
- package/skills/engineering/resolve-release/references/regression-checklist.md +36 -0
- package/skills/engineering/resolve-release/references/related-skills.md +12 -0
- package/skills/engineering/resolve-release/references/routing.md +149 -0
- package/skills/engineering/resolve-release/references/verified-sha-github-flow.md +285 -0
- package/skills/engineering/resolve-release/references/versioning.md +202 -0
- package/skills/engineering/resolve-release/references/why.md +53 -0
- package/skills/engineering/resolve-release/scripts/adapter-completion-artifact.mjs +389 -0
- package/skills/engineering/resolve-release/scripts/build-changes.mjs +209 -0
- package/skills/engineering/resolve-release/scripts/candidate-hygiene.mjs +407 -0
- package/skills/engineering/resolve-release/scripts/candidate-identity.mjs +904 -0
- package/skills/engineering/resolve-release/scripts/candidate-traffic.mjs +81 -0
- package/skills/engineering/resolve-release/scripts/checked-adapter-loader.mjs +612 -0
- package/skills/engineering/resolve-release/scripts/close-attempt.mjs +135 -0
- package/skills/engineering/resolve-release/scripts/closeout-release.mjs +161 -0
- package/skills/engineering/resolve-release/scripts/doctrine.mjs +106 -0
- package/skills/engineering/resolve-release/scripts/durable-processing.mjs +522 -0
- package/skills/engineering/resolve-release/scripts/ensure-target-green.mjs +659 -0
- package/skills/engineering/resolve-release/scripts/evidence-bundle.mjs +1014 -0
- package/skills/engineering/resolve-release/scripts/finalize-release.mjs +526 -0
- package/skills/engineering/resolve-release/scripts/fixtures/durable-processing-adapter.mjs +169 -0
- package/skills/engineering/resolve-release/scripts/green-gate.mjs +599 -0
- package/skills/engineering/resolve-release/scripts/isolated-adapter-evaluator.mjs +752 -0
- package/skills/engineering/resolve-release/scripts/metadata-pr-status.mjs +56 -0
- package/skills/engineering/resolve-release/scripts/metadata-sync.mjs +1538 -0
- package/skills/engineering/resolve-release/scripts/postmortem.mjs +381 -0
- package/skills/engineering/resolve-release/scripts/preflight-probes.mjs +498 -0
- package/skills/engineering/resolve-release/scripts/production-endpoints.mjs +326 -0
- package/skills/engineering/resolve-release/scripts/rc-circuit-breaker.mjs +272 -0
- package/skills/engineering/resolve-release/scripts/report.mjs +417 -0
- package/skills/engineering/resolve-release/scripts/reprobe-credentials.mjs +114 -0
- package/skills/engineering/resolve-release/scripts/revalidate-candidate.mjs +238 -0
- package/skills/engineering/resolve-release/scripts/review-packet.mjs +503 -0
- package/skills/engineering/resolve-release/scripts/rollback-floor.mjs +263 -0
- package/skills/engineering/resolve-release/scripts/version-assert.mjs +339 -0
- package/skills/engineering/resolve-release/scripts/version-postmortem.mjs +485 -0
- package/skills/engineering/resolve-release/scripts/version.mjs +1199 -0
- package/skills/engineering/resolve-release/scripts/watch-candidate-delivery.mjs +449 -0
- package/skills/engineering/resolve-release/vendor/ACORN-LICENSE +21 -0
- package/skills/engineering/resolve-release/vendor/README.md +60 -0
- package/skills/engineering/resolve-release/vendor/acorn.mjs +6233 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/LICENSE +21 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/README.md +341 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/README.template.md +70 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-TAV5CUKK.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs +7 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.mts +2033 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.d.ts +2033 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js +7 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.js.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/index.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-ES6BEMUI.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/core/package.json +49 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/LICENSE +21 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/README.md +5 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.mts +549 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.d.ts +549 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.js.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/dist/index.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/ffi-types/package.json +36 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/LICENSE +47 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/README.md +82 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.browser.mjs +22 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cjs +21 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.d.ts +11 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.mjs +25 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/emscripten-module.wasm +0 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.mts +85 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.d.ts +85 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.js.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/ffi.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.mts +20 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.d.ts +20 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.js.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs +2 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/dist/index.mjs.map +1 -0
- package/skills/engineering/resolve-release/vendor/quickjs/release-sync/package.json +61 -0
- package/skills/engineering/review-pr/LICENSE +3 -0
- package/skills/engineering/review-pr/SKILL.md +123 -0
- package/skills/engineering/review-pr/references/adversarial-reviewer-prompt.md +42 -0
- package/skills/engineering/review-pr/references/code-correctness.md +5 -0
- package/skills/engineering/review-pr/references/contract-freshness.md +9 -0
- package/skills/engineering/review-pr/references/coordination.md +18 -0
- package/skills/engineering/review-pr/references/domain-hazards.md +123 -0
- package/skills/engineering/review-pr/references/finding-themes.md +7 -0
- package/skills/engineering/review-pr/references/github-posting.md +98 -0
- package/skills/engineering/review-pr/references/golden-path-smoke.md +5 -0
- package/skills/engineering/review-pr/references/incremental-output.md +16 -0
- package/skills/engineering/review-pr/references/inputs-and-discovery.md +31 -0
- package/skills/engineering/review-pr/references/output-format.md +99 -0
- package/skills/engineering/review-pr/references/over-mock-screen.md +7 -0
- package/skills/engineering/review-pr/references/promotion-prs.md +16 -0
- package/skills/engineering/review-pr/references/re-review.md +18 -0
- package/skills/engineering/review-pr/references/review-method.md +199 -0
- package/skills/engineering/review-pr/references/review-mode.md +30 -0
- package/skills/engineering/review-pr/references/review-posture.md +53 -0
- package/skills/engineering/review-pr/references/round1-depth.md +62 -0
- package/skills/engineering/review-pr/references/scripts.md +17 -0
- package/skills/engineering/review-pr/references/workflow.md +16 -0
- package/skills/engineering/review-pr/schemas/findings.schema.json +282 -0
- package/skills/engineering/review-pr/scripts/finding-contract.mjs +285 -0
- package/skills/engineering/review-pr/scripts/post-review.mjs +405 -0
- package/skills/engineering/review-pr/scripts/pr-context.mjs +207 -0
- package/skills/engineering/review-pr/scripts/scan-diff.mjs +365 -0
- package/skills/engineering/review-pr/scripts/theme-contract.mjs +57 -0
- package/skills/engineering/smoke/LICENSE +3 -0
- package/skills/engineering/smoke/SKILL.md +131 -0
- package/skills/engineering/smoke/assets/smoke.manifest.example.json +53 -0
- package/skills/engineering/smoke/references/manifest.md +192 -0
- package/skills/engineering/smoke/scripts/smoke.mjs +713 -0
- package/skills/fullstack/better-auth/LICENSE +3 -0
- package/skills/fullstack/better-auth/SKILL.md +601 -0
- package/skills/fullstack/better-auth/references/feishu-api.md +270 -0
- package/skills/fullstack/monorepo/LICENSE +3 -0
- package/skills/fullstack/monorepo/SKILL.md +465 -0
- package/skills/fullstack/nextjs-fullstack/LICENSE +3 -0
- package/skills/fullstack/nextjs-fullstack/SKILL.md +210 -0
- package/skills/fullstack/nextjs-fullstack/conventions.md +318 -0
- package/skills/fullstack/nextjs-fullstack/frontend-conventions.md +61 -0
- package/skills/fullstack/nextjs-fullstack/nextjs16.md +287 -0
- package/skills/fullstack/nextjs-fullstack/server-actions.md +409 -0
- package/skills/fullstack/prisma-setup/LICENSE +3 -0
- package/skills/fullstack/prisma-setup/SKILL.md +180 -0
- package/skills/fullstack/prisma-setup/nextjs.md +258 -0
- package/skills/fullstack/prisma-setup/turborepo.md +301 -0
- package/skills/fullstack/shadcn/LICENSE +3 -0
- package/skills/fullstack/shadcn/SKILL.md +119 -0
- package/skills/fullstack/shadcn/assets/shadcn-small.png +0 -0
- package/skills/fullstack/shadcn/assets/shadcn.png +0 -0
- package/skills/fullstack/shadcn/cli.md +411 -0
- package/skills/fullstack/shadcn/customization.md +224 -0
- package/skills/fullstack/shadcn/evals/evals.json +90 -0
- package/skills/fullstack/shadcn/mcp.md +101 -0
- package/skills/fullstack/shadcn/rules/base-vs-radix.md +323 -0
- package/skills/fullstack/shadcn/rules/component-selection.md +67 -0
- package/skills/fullstack/shadcn/rules/composition.md +195 -0
- package/skills/fullstack/shadcn/rules/data-table.md +201 -0
- package/skills/fullstack/shadcn/rules/forms.md +255 -0
- package/skills/fullstack/shadcn/rules/icons.md +103 -0
- package/skills/fullstack/shadcn/rules/styling.md +167 -0
- package/skills/fullstack/zod-v4/LICENSE +3 -0
- package/skills/fullstack/zod-v4/SKILL.md +287 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
// NOT A CLI. Running this file directly does nothing — no output, exit 0 — because it has no
|
|
2
|
+
// entry point. `run-state.mjs postmortem <slug> [--json]` is the entry point,
|
|
3
|
+
// and it does work this module cannot: it derives scheduler/manifest context this file never sees.
|
|
4
|
+
//
|
|
5
|
+
// A guard that says so out loud was tried and reverted. It has to import the shared main-module
|
|
6
|
+
// helper from `engineering-runtime`, and `run-state.mjs` imports THIS file at load — so the new
|
|
7
|
+
// edge changed which module a runtime-missing install reports first, turning an actionable "the
|
|
8
|
+
// coherence contract is missing" into "a helper is missing". Degrading an operator-facing
|
|
9
|
+
// fail-closed diagnostic to catch an authoring mistake is the wrong trade; the docs now link the
|
|
10
|
+
// command text at `run-state.mjs`, and a test pins that they keep doing so.
|
|
11
|
+
// The run's post-mortem: what the loop spent its wall-clock on, and what to change.
|
|
12
|
+
//
|
|
13
|
+
// MANDATORY and AUTOMATIC. It runs at the handoff whether or not anyone asks, and it
|
|
14
|
+
// persists to disk without being asked, because the diagnostic value is CROSS-RUN: one
|
|
15
|
+
// run's profile is an anecdote, and the question worth answering — is this loop getting
|
|
16
|
+
// faster or slower, and at what — needs the last twenty. A pass that runs only when
|
|
17
|
+
// someone already suspects a problem never runs on the runs that set the baseline.
|
|
18
|
+
//
|
|
19
|
+
// It is also the pass most likely to be skipped, for a reason worth naming: it fires at
|
|
20
|
+
// the handoff, when the run is over and everyone involved believes they know how it went.
|
|
21
|
+
// That belief is the thing it exists to contradict, so it does not ask permission.
|
|
22
|
+
//
|
|
23
|
+
// Why a renderer rather than a step that says "reflect on the run". The findings below
|
|
24
|
+
// were all found by hand, once, on `epic-563-admin-transactions` (2026-08-07: 4 units,
|
|
25
|
+
// 626 minutes, ~10.4 hours). Every one of them is a fact the manifest already held and
|
|
26
|
+
// nobody read: three rounds bought after a PASS, a unit admitted at minute 347 of 626, a
|
|
27
|
+
// cap of 2 on a four-unit roster, `pushedSha` unrecorded on every unit. A prose
|
|
28
|
+
// instruction to "profile the run" degrades into a summary of what the orchestrator
|
|
29
|
+
// remembers, which is the narration it already produced and already believes. Computing
|
|
30
|
+
// them from state is the whole point: the post-mortem's job is to disagree with the
|
|
31
|
+
// narration.
|
|
32
|
+
//
|
|
33
|
+
// The honesty rule is report.mjs's, unchanged: never invent what the manifest does not
|
|
34
|
+
// hold. This module reads ONLY the run manifest — no transcripts, no harness paths, no
|
|
35
|
+
// clock — so it runs identically in a consumer repo, in CI, and on a resumed run. That
|
|
36
|
+
// bounds what it can see (it cannot time a spawn, so it cannot tell you a producer ran
|
|
37
|
+
// slowly) and everything it does see is a fact with a timestamp behind it.
|
|
38
|
+
|
|
39
|
+
import fs from 'node:fs'
|
|
40
|
+
import os from 'node:os'
|
|
41
|
+
import path from 'node:path'
|
|
42
|
+
|
|
43
|
+
import { DEFAULT_IN_FLIGHT_CAP, deriveComponents, isResolvedUnit, isDoneUnit } from './interference.mjs'
|
|
44
|
+
import { deliveryForUnit, terminalDisposition } from './terminal-dispositions.mjs'
|
|
45
|
+
import { roundsAfterPass } from './fix-rounds.mjs'
|
|
46
|
+
|
|
47
|
+
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
|
|
48
|
+
const arr = (v) => (Array.isArray(v) ? v.filter(isObject) : [])
|
|
49
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== ''
|
|
50
|
+
|
|
51
|
+
// ---- where the profiles live ----
|
|
52
|
+
//
|
|
53
|
+
// Never the DEFAULT run dir, which is `$TMPDIR/resolver-runs` and gets cleared by the OS —
|
|
54
|
+
// right for a manifest, whose job ends with its run, and fatal for a profile, whose entire
|
|
55
|
+
// job is to still be there twenty runs later. Three levels, most specific first:
|
|
56
|
+
//
|
|
57
|
+
// RESOLVER_POSTMORTEM_DIR explicit, wins outright
|
|
58
|
+
// RESOLVER_RUN_DIR a caller who set this CHOSE a location, so it is durable by
|
|
59
|
+
// their decision — profiles go in a subdirectory of it. This
|
|
60
|
+
// also scopes every test that already points the run dir at a
|
|
61
|
+
// tmpdir, so a CLI test cannot write into the developer's home.
|
|
62
|
+
// (neither) XDG state — never tmp
|
|
63
|
+
//
|
|
64
|
+
// Slug sanitising is duplicated from run-state.mjs rather than imported: run-state.mjs
|
|
65
|
+
// imports THIS module, and reaching back would make the cycle real. It is one regex, and
|
|
66
|
+
// the alternative is an import cycle for a shared one-liner.
|
|
67
|
+
export function postmortemDir(env = process.env) {
|
|
68
|
+
if (nonEmpty(env?.RESOLVER_POSTMORTEM_DIR)) return env.RESOLVER_POSTMORTEM_DIR.replace(/\/+$/, '')
|
|
69
|
+
if (nonEmpty(env?.RESOLVER_RUN_DIR)) return path.join(env.RESOLVER_RUN_DIR.replace(/\/+$/, ''), 'postmortems')
|
|
70
|
+
const state = nonEmpty(env?.XDG_STATE_HOME)
|
|
71
|
+
? env.XDG_STATE_HOME.replace(/\/+$/, '')
|
|
72
|
+
: path.join(os.homedir(), '.local', 'state')
|
|
73
|
+
return path.join(state, 'resolver-postmortems')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const safeSlug = (slug) => String(slug ?? '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'run'
|
|
77
|
+
|
|
78
|
+
export function postmortemPaths(slug, env = process.env) {
|
|
79
|
+
const dir = postmortemDir(env)
|
|
80
|
+
const base = safeSlug(slug)
|
|
81
|
+
return {
|
|
82
|
+
dir,
|
|
83
|
+
json: path.join(dir, `${base}.json`),
|
|
84
|
+
text: path.join(dir, `${base}.txt`),
|
|
85
|
+
index: path.join(dir, 'index.jsonl'),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ms = (a, b) => {
|
|
90
|
+
const x = Date.parse(a); const y = Date.parse(b)
|
|
91
|
+
return Number.isFinite(x) && Number.isFinite(y) ? y - x : null
|
|
92
|
+
}
|
|
93
|
+
const mins = (v) => (v == null ? null : Math.round(v / 60000))
|
|
94
|
+
const fmt = (m) => (m == null ? '—' : m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`)
|
|
95
|
+
|
|
96
|
+
// A unit's own timeline, to the extent the manifest recorded one. `rounds[].at` is
|
|
97
|
+
// optional (validateRoundLog allows it absent), so every consumer below tolerates null
|
|
98
|
+
// rather than treating a missing timestamp as zero — a run that did not stamp its rounds
|
|
99
|
+
// gets fewer findings, not wrong ones.
|
|
100
|
+
// Prefer, in order (#791 / issue-664 / identity#669):
|
|
101
|
+
// 1. delivery.mergedAt — GitHub's merge time / verified-merge clock
|
|
102
|
+
// 2. attempt.at when that attempt's outcome is `merged`
|
|
103
|
+
// 3. null (no delivery clock)
|
|
104
|
+
// A late record-delivery-* after the PR already merged must not inflate unitSpan.delivered
|
|
105
|
+
// into a multi-hour co-delivery finding. `outcomeWritten` keeps the bookkeeping clock so
|
|
106
|
+
// findOutcomeWriteLag can name the lag separately.
|
|
107
|
+
export function unitSpan(unit, manifest = null) {
|
|
108
|
+
const delivery = deliveryForUnit(unit, manifest)
|
|
109
|
+
const rounds = arr(unit.rounds).filter((r) => nonEmpty(r.at))
|
|
110
|
+
const attempts = arr(delivery?.attempts).filter((a) => nonEmpty(a.at))
|
|
111
|
+
const firstRound = rounds[0]?.at ?? null
|
|
112
|
+
const lastRound = rounds.length ? rounds[rounds.length - 1].at : null
|
|
113
|
+
const firstPass = rounds.find((r) => r.verdict === 'PASS')?.at ?? null
|
|
114
|
+
const mergedAttemptAt = attempts.filter((a) => a.outcome === 'merged').slice(-1)[0]?.at
|
|
115
|
+
?? (delivery?.outcome === 'merged' ? (attempts.slice(-1)[0]?.at ?? null) : null)
|
|
116
|
+
const mergedAt = nonEmpty(delivery?.mergedAt) ? delivery.mergedAt : null
|
|
117
|
+
const delivered = delivery?.outcome === 'merged'
|
|
118
|
+
? (mergedAt ?? mergedAttemptAt)
|
|
119
|
+
: null
|
|
120
|
+
// Bookkeeping clock: when the terminal outcome / last attempt was written. Distinct from
|
|
121
|
+
// `delivered` so a late write after a real merge can be named without inventing wait.
|
|
122
|
+
const outcomeWritten = delivery?.outcome === 'merged'
|
|
123
|
+
? (delivery.outcomeAt ?? attempts.slice(-1)[0]?.at ?? null)
|
|
124
|
+
: null
|
|
125
|
+
return { firstRound, lastRound, firstPass, delivered, outcomeWritten, mergedAt, roundCount: arr(unit.rounds).length }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The run's wall-clock, from `createdAt` to the latest timestamp anything recorded —
|
|
129
|
+
// including `updatedAt`, which is the only one that moves for work after the last round
|
|
130
|
+
// (delivery watches, the handoff itself). Taking the max over rounds alone under-measures
|
|
131
|
+
// every run that did anything after its final verdict, which is all of them.
|
|
132
|
+
function runMinutes(m) {
|
|
133
|
+
if (!nonEmpty(m?.createdAt)) return null
|
|
134
|
+
const stamps = [m.updatedAt, ...arr(m.units).flatMap((u) => [
|
|
135
|
+
...arr(u.rounds).map((r) => r.at),
|
|
136
|
+
...arr(u.delivery?.attempts).map((a) => a.at),
|
|
137
|
+
]), ...arr(m.components).flatMap((component) => [
|
|
138
|
+
...arr(component.delivery?.attempts).map((attempt) => attempt.at),
|
|
139
|
+
...arr(component.delivery?.observations).map((observation) => observation.at),
|
|
140
|
+
component.delivery?.mergedAt,
|
|
141
|
+
component.delivery?.outcomeAt,
|
|
142
|
+
])].filter(nonEmpty).filter((s) => Number.isFinite(Date.parse(s)))
|
|
143
|
+
if (!stamps.length) return null
|
|
144
|
+
const end = stamps.reduce((latest, s) => (Date.parse(s) > Date.parse(latest) ? s : latest), stamps[0])
|
|
145
|
+
const d = mins(ms(m.createdAt, end))
|
|
146
|
+
return d != null && d > 0 ? d : null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- the findings ----
|
|
150
|
+
//
|
|
151
|
+
// Each is { id, severity, headline, detail, evidence[], recommendation }. Ranked by
|
|
152
|
+
// measured cost where the manifest supports a number and by severity where it does not —
|
|
153
|
+
// an unranked list of six equal-looking findings is a list nobody acts on.
|
|
154
|
+
|
|
155
|
+
// Buckets a reported round's cost on `round.headMoved` — the label #760's classification
|
|
156
|
+
// already produces, READ here, never re-derived from headSha. `true` (demoted/probable
|
|
157
|
+
// rebase) rounds get their own subtotal (`demotedCostMinutes`), reported but never fed
|
|
158
|
+
// into `costMinutes` — the field `profile()`'s ranking comparator reads — because a
|
|
159
|
+
// demoted round's gap is dominated by queue wait (a rebase's overnight wait, not review
|
|
160
|
+
// time) and blending it into the ranked figure would reintroduce the same order of
|
|
161
|
+
// inflation #761 exists to kill, one seam downstream of where it was found. Anything else
|
|
162
|
+
// (`false`/unowed, `null`/unadjudicated) is the finding's actual claim.
|
|
163
|
+
//
|
|
164
|
+
// This is still elapsed time, not review time, in EITHER bucket — the gap before a round
|
|
165
|
+
// includes queue wait as well as the round itself. That is fully owed for a demoted round
|
|
166
|
+
// (a real rebase's wait was never unowed) and is exactly why its cost stays out of the
|
|
167
|
+
// ranked figure; for an unowed round it is defensible only because the round itself
|
|
168
|
+
// should not have existed, so none of the elapsed time before it was owed either.
|
|
169
|
+
//
|
|
170
|
+
// A round whose gap cannot be computed drops OUT of its bucket's sum rather than folding in
|
|
171
|
+
// as a silent zero (AC3) — each bucket tracks its own `*Unknown` count, so "this round
|
|
172
|
+
// genuinely cost nothing" and "this round's cost could not be computed" never collapse into
|
|
173
|
+
// the same reported number, in either bucket.
|
|
174
|
+
//
|
|
175
|
+
// TWO things make a gap uncomputable, and they have to be treated alike (#771). The obvious
|
|
176
|
+
// one is a missing `at`/`priorAt`. The other is a NEGATIVE gap — the round's own timestamp
|
|
177
|
+
// earlier than the round before it — which is reachable without any bug here:
|
|
178
|
+
// `validateRoundLog` never ties round order to time order, so an out-of-order log is valid;
|
|
179
|
+
// `roundsAfterPass` walks array order without sorting; and this reads a SAVED manifest that
|
|
180
|
+
// nothing re-validates. Clock skew across hosts, a hand-edited manifest, or timestamps
|
|
181
|
+
// written out of order during recovery all produce it. Clamping such a gap to zero would
|
|
182
|
+
// charge nothing AND count the round as successfully priced — arriving at the same silent
|
|
183
|
+
// zero AC3 exists to forbid, by a second route and with nothing tracking it. So a negative
|
|
184
|
+
// gap is unpriceable, exactly like a missing one.
|
|
185
|
+
//
|
|
186
|
+
// The sign check has to run on the RAW millisecond gap, before `mins()` rounds it (#771
|
|
187
|
+
// reopened): `mins()` is `Math.round(v / 60000)`, and `Math.round(-0.5) === -0` in JS, so a
|
|
188
|
+
// backwards gap in [-30s, 0) — clock skew's common magnitude, not the rare large inversion —
|
|
189
|
+
// rounds to `-0`, and `-0 >= 0` is `true`. Guarding the rounded value let exactly that range
|
|
190
|
+
// through, byte-identical to the `Math.max(d, 0)` clamp this fix exists to remove.
|
|
191
|
+
export function bucketRoundCost(hits) {
|
|
192
|
+
let cost = 0
|
|
193
|
+
let costUnknown = 0
|
|
194
|
+
let demotedCost = 0
|
|
195
|
+
let demotedUnknown = 0
|
|
196
|
+
let demotedCount = 0
|
|
197
|
+
for (const { round } of arr(hits)) {
|
|
198
|
+
const gapMs = nonEmpty(round.at) && nonEmpty(round.priorAt) ? ms(round.priorAt, round.at) : null
|
|
199
|
+
// `>= 0` rather than `Math.max(d, 0)`: a zero gap is a real measurement (two rounds
|
|
200
|
+
// logged inside the same minute), a negative one is corrupt input. Tested on `gapMs`,
|
|
201
|
+
// not on `mins(gapMs)` — see the comment above the function for why.
|
|
202
|
+
const d = gapMs != null && gapMs >= 0 ? mins(gapMs) : null
|
|
203
|
+
if (round.headMoved === true) {
|
|
204
|
+
demotedCount += 1
|
|
205
|
+
if (d == null) demotedUnknown += 1
|
|
206
|
+
else demotedCost += d
|
|
207
|
+
} else if (d == null) {
|
|
208
|
+
costUnknown += 1
|
|
209
|
+
} else {
|
|
210
|
+
cost += d
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const unowedCount = arr(hits).length - demotedCount
|
|
214
|
+
return {
|
|
215
|
+
costMinutes: costUnknown < unowedCount ? cost : null,
|
|
216
|
+
costUnknown,
|
|
217
|
+
demotedCostMinutes: demotedCount ? (demotedUnknown < demotedCount ? demotedCost : null) : null,
|
|
218
|
+
demotedUnknown,
|
|
219
|
+
demotedCount,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function findRoundsAfterPass(m) {
|
|
224
|
+
// A round the log itself proves was a rewritten head (`headMoved === true`) is probably a
|
|
225
|
+
// rebase — but the log cannot prove it WAS one, since closing a finding the reviewer itself
|
|
226
|
+
// called non-blocking also produces a new commit (why.md's epic-563 measurement: #572 had
|
|
227
|
+
// one of each). So a moved head is demoted, not dropped: still reported, ranked after the
|
|
228
|
+
// rounds the log cannot explain any other way, and labelled a probable rebase for a human
|
|
229
|
+
// to confirm. What ranks first is `false` (proven same head — the log rules OUT a rebase,
|
|
230
|
+
// so this is the residue) and `null` (headSha missing on one side, so the log cannot tell
|
|
231
|
+
// and falls back to reporting it, AC5). Kept as structured `{ unit, round }` pairs, not just
|
|
232
|
+
// rendered into `evidence` strings below: `round` still carries `at`/`priorAt`/`headSha`/
|
|
233
|
+
// `headMoved`, which is the seam a per-round cost fix (#761) reads rather than re-deriving
|
|
234
|
+
// this same filter over the raw manifest.
|
|
235
|
+
const hits = arr(m.units).flatMap((u) => roundsAfterPass(u).map((r) => ({ unit: u.id, round: r })))
|
|
236
|
+
if (!hits.length) return null
|
|
237
|
+
const undemoted = hits.filter((h) => h.round.headMoved !== true)
|
|
238
|
+
const demoted = hits.filter((h) => h.round.headMoved === true)
|
|
239
|
+
const ordered = [...undemoted, ...demoted]
|
|
240
|
+
const totalRounds = arr(m.units).reduce((n, u) => n + arr(u.rounds).length, 0)
|
|
241
|
+
// The cost is the gap between each reported round and the round immediately before it
|
|
242
|
+
// (`round.priorAt` -> `round.at`), bucketed by `headMoved` so a demoted round's
|
|
243
|
+
// queue-wait-heavy gap can never blend into the ranked `costMinutes` (#761). See
|
|
244
|
+
// `bucketRoundCost`'s own comment for why this replaced a per-unit span.
|
|
245
|
+
const bucketed = bucketRoundCost(hits)
|
|
246
|
+
return {
|
|
247
|
+
id: 'rounds-after-pass',
|
|
248
|
+
// The demotion has to reach the SIGNALS a reader skims, not just the evidence lines
|
|
249
|
+
// (#767). A finding whose actionable count is zero — every round after the PASS moved
|
|
250
|
+
// its head, so the log calls them all probable rebases — is not the run's worst problem,
|
|
251
|
+
// and leaving it HIGH puts it above findings that cost something real. `costMinutes`
|
|
252
|
+
// alone does not fix the order, because `profile()` sorts severity FIRST and only then
|
|
253
|
+
// by cost: on epic-563 the unowed bucket is empty, so cost collapses to null, and the
|
|
254
|
+
// finding still outranked a medium worth 189 minutes until severity moved too.
|
|
255
|
+
//
|
|
256
|
+
// Demoted to medium rather than low because the rounds are unconfirmed, not cleared —
|
|
257
|
+
// postmortem.md still obliges adjudicating every named round, demoted ones included.
|
|
258
|
+
severity: undemoted.length ? 'high' : 'medium',
|
|
259
|
+
costMinutes: bucketed.costMinutes,
|
|
260
|
+
costUnknown: bucketed.costUnknown,
|
|
261
|
+
demotedCostMinutes: bucketed.demotedCostMinutes,
|
|
262
|
+
demotedUnknown: bucketed.demotedUnknown,
|
|
263
|
+
demotedCount: bucketed.demotedCount,
|
|
264
|
+
// The leading integer counts what the finding is NAMED for — rounds bought after a PASS,
|
|
265
|
+
// all of them — and the split follows behind it (#767). Leading with the undemoted count
|
|
266
|
+
// instead made the headline contradict its own title: on epic-563 it read "0 of 8 review
|
|
267
|
+
// rounds were bought after a PASS on an unmoved head" when three rounds plainly were
|
|
268
|
+
// bought after a PASS, so the number a reader skims had quietly changed denominator and
|
|
269
|
+
// the founding "3 of 8" looked like it had fallen to zero. Same facts either way, same
|
|
270
|
+
// qualification, nothing dropped — but this order keeps the count continuous with the
|
|
271
|
+
// finding's name and puts the narrowing where it reads as a narrowing.
|
|
272
|
+
headline: demoted.length
|
|
273
|
+
? `${hits.length} of ${totalRounds} review rounds were bought after a PASS — ${undemoted.length} on an unmoved head, ${demoted.length} on a moved head (probable rebases, unconfirmed)`
|
|
274
|
+
: `${hits.length} of ${totalRounds} review rounds were bought after a PASS`,
|
|
275
|
+
detail: 'A PASS is terminal (SKILL.md guarantee 3). A round logged after one owes a carve-out — red CI on that head, a rewritten head, or a landed contract revision. The log proves WHETHER a head moved but not WHY: a genuine rebase and a fix round that closes findings the reviewer itself called non-blocking both produce a new commit, so a moved head is reported here too — demoted below the rounds the log truly cannot explain, and labelled a probable rebase. "The notes inside the PASS were worth closing" is not a carve-out for either bucket.',
|
|
276
|
+
evidence: ordered.map(({ unit, round }) => `${unit} round ${round.round} (${round.verdict} @ ${round.headSha ?? '—'}) followed round ${round.passedAt.round}'s PASS${
|
|
277
|
+
round.headMoved === true ? ' — head moved, probably a rebase (unconfirmed — could be a fix round closing non-blocking notes)'
|
|
278
|
+
: round.headMoved === null ? ' — headSha missing on one side, could not adjudicate'
|
|
279
|
+
: ' — same head as the round before it'
|
|
280
|
+
}`),
|
|
281
|
+
recommendation: 'Deliver on the PASS and file the notes as deferrals[] with tracking issues. A round whose head moved is reported separately, demoted, and labelled a probable rebase — confirm which it was from the round log\'s own notes, since the signal cannot tell a rebase from a fix round that closes non-blocking findings; both move the head the same way. If a round on an unmoved head was still legitimate (a held verdict, a landed contract revision with no code delta), say so there too.',
|
|
282
|
+
rounds: ordered,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Serialization, measured as the STAGGER BETWEEN UNITS rather than each unit's offset
|
|
287
|
+
// from the run start. The difference matters and the naive version is a trap: the run's
|
|
288
|
+
// end is defined by the last thing that happened, which is usually the last unit's last
|
|
289
|
+
// round, so that unit's "offset from start" is ~100% of the run BY CONSTRUCTION. Reported
|
|
290
|
+
// that way it looks like the worst finding in the run and means nothing. The gap from the
|
|
291
|
+
// first unit's first round to the last unit's first round is a real quantity — it is how
|
|
292
|
+
// much of the run had already elapsed before the roster was fully in flight — and no unit
|
|
293
|
+
// can produce it trivially.
|
|
294
|
+
function findStaggeredStart(m) {
|
|
295
|
+
const starts = arr(m.units).map((u) => ({ id: u.id, ...unitSpan(u) }))
|
|
296
|
+
.filter((s) => nonEmpty(s.firstRound))
|
|
297
|
+
.sort((a, b) => Date.parse(a.firstRound) - Date.parse(b.firstRound))
|
|
298
|
+
if (starts.length < 2) return null
|
|
299
|
+
const runLen = runMinutes(m)
|
|
300
|
+
const stagger = mins(ms(starts[0].firstRound, starts[starts.length - 1].firstRound))
|
|
301
|
+
if (!runLen || runLen <= 0 || stagger == null || stagger <= 0) return null
|
|
302
|
+
// Fires when the roster took longer than half the run to fully enter it. Below that the
|
|
303
|
+
// stagger is ordinary — units genuinely finish at different times.
|
|
304
|
+
if (stagger < runLen / 2) return null
|
|
305
|
+
return {
|
|
306
|
+
id: 'staggered-start',
|
|
307
|
+
severity: 'high',
|
|
308
|
+
costMinutes: stagger,
|
|
309
|
+
headline: `the roster took ${fmt(stagger)} of a ${fmt(runLen)} run to fully enter it`,
|
|
310
|
+
detail: 'Units that start far apart cannot overlap, so the run is serial in wall-clock even where the graph allows parallelism. Ask `run-state.mjs runnable <slug>` at every milestone: the bar is *pushed*, never *merged*, and a premise edge orders delivery rather than the build.',
|
|
311
|
+
evidence: starts.map((s) => `${s.id} first round at +${fmt(mins(ms(starts[0].firstRound, s.firstRound)))} after the first unit's`),
|
|
312
|
+
recommendation: 'For each late unit, replay the manifest through `runnable` at the point it was queued. If it says ADMIT NOW, the unit was not blocked — the scheduler was simply not consulted.',
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function findCapBelowDefault(m) {
|
|
317
|
+
const cap = m.inFlightCap
|
|
318
|
+
if (!Number.isSafeInteger(cap) || cap >= DEFAULT_IN_FLIGHT_CAP) return null
|
|
319
|
+
const roster = arr(m.units).length
|
|
320
|
+
return {
|
|
321
|
+
id: 'cap-below-default',
|
|
322
|
+
severity: roster > cap ? 'high' : 'low',
|
|
323
|
+
costMinutes: null,
|
|
324
|
+
headline: `inFlightCap was ${cap} on a roster of ${roster}`,
|
|
325
|
+
detail: `The cap is ${DEFAULT_IN_FLIGHT_CAP} on every repo and the loop does not choose it. It bounds disk and dependency-install cost, never correctness, and it is read once and then never revisited — so a low value written in the first minutes silently prices the whole run.`,
|
|
326
|
+
evidence: [`inFlightCap: ${cap}`, `units: ${roster}`],
|
|
327
|
+
recommendation: 'Drop the field unless a human measured a slow install here. Replay `runnable` at both values to see what the difference actually admitted.',
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function findUnrecordedPush(m) {
|
|
332
|
+
const stranded = arr(m.units).filter((u) => !isResolvedUnit(u) && !nonEmpty(u.pushedSha) && !nonEmpty(u.pr))
|
|
333
|
+
const missingSha = arr(m.units).filter((u) => !nonEmpty(u.pushedSha))
|
|
334
|
+
if (!missingSha.length) return null
|
|
335
|
+
return {
|
|
336
|
+
id: 'pushed-sha-unrecorded',
|
|
337
|
+
severity: stranded.length ? 'high' : 'medium',
|
|
338
|
+
costMinutes: null,
|
|
339
|
+
headline: `${missingSha.length} of ${arr(m.units).length} units never recorded pushedSha`,
|
|
340
|
+
detail: '`pushedSha` is what makes a unit\'s dependents runnable. A recorded `pr` covers the same ground (a PR cannot exist over an unpushed head), so a unit with neither is the one that actually strands its dependents.',
|
|
341
|
+
evidence: [
|
|
342
|
+
...missingSha.map((u) => `${u.id}: pushedSha unrecorded${nonEmpty(u.pr) ? ` (pr ${u.pr} covers it)` : ''}`),
|
|
343
|
+
...stranded.map((u) => `${u.id}: NEITHER pushedSha nor pr — dependents cannot be released`),
|
|
344
|
+
],
|
|
345
|
+
recommendation: 'Derive it rather than remember it: read `gh pr view <pr> --json headRefOid` in the turn the producer returns and upsert it with the PR number.',
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function deliveryComponents(m) {
|
|
350
|
+
const units = arr(m.units)
|
|
351
|
+
const byId = new Map(units.map((unit) => [unit.id, unit]))
|
|
352
|
+
const declared = arr(m.components)
|
|
353
|
+
const declaredMembers = (memberRefs) => {
|
|
354
|
+
const seen = new Set()
|
|
355
|
+
return (Array.isArray(memberRefs) ? memberRefs : []).map((entry, index) => {
|
|
356
|
+
const id = typeof entry === 'string' && nonEmpty(entry)
|
|
357
|
+
? entry
|
|
358
|
+
: isObject(entry) && nonEmpty(entry.id) ? entry.id : null
|
|
359
|
+
const duplicate = id != null && seen.has(id)
|
|
360
|
+
if (id != null) seen.add(id)
|
|
361
|
+
const valid = id != null
|
|
362
|
+
const unit = valid ? byId.get(id) ?? null : null
|
|
363
|
+
return { index, id, valid, resolved: unit != null, duplicate, unit }
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
if (!declared.length) return deriveComponents(units).map((component) => ({
|
|
367
|
+
...component,
|
|
368
|
+
memberRefs: component.units.map((unit, index) => ({
|
|
369
|
+
index, id: unit.id, valid: true, resolved: true, duplicate: false, unit,
|
|
370
|
+
})),
|
|
371
|
+
}))
|
|
372
|
+
const claimed = new Set()
|
|
373
|
+
const groups = declared.map((component) => {
|
|
374
|
+
const members = declaredMembers(component.units)
|
|
375
|
+
for (const member of members) if (member.unit != null) claimed.add(member.unit.id)
|
|
376
|
+
return { ...component, id: component.id, memberRefs: members, units: members.map((member) => member.unit).filter(Boolean) }
|
|
377
|
+
})
|
|
378
|
+
for (const unit of units) if (!claimed.has(unit.id)) groups.push({
|
|
379
|
+
id: `component:${unit.id}`, units: [unit],
|
|
380
|
+
memberRefs: [{ index: 0, id: unit.id, valid: true, resolved: true, duplicate: false, unit }],
|
|
381
|
+
})
|
|
382
|
+
return groups
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function terminalUnitPass(unit) {
|
|
386
|
+
const rounds = arr(unit.rounds)
|
|
387
|
+
const firstPassIndex = rounds.findIndex((round) => round.verdict === 'PASS' && nonEmpty(round.at))
|
|
388
|
+
if (firstPassIndex < 0) return null
|
|
389
|
+
let readyPass = rounds[firstPassIndex].at
|
|
390
|
+
for (let index = firstPassIndex + 1; index < rounds.length; index++) {
|
|
391
|
+
const round = rounds[index]
|
|
392
|
+
if (round.verdict !== 'PASS' || !nonEmpty(round.at)) continue
|
|
393
|
+
const reconstructedReopen = rounds.slice(firstPassIndex + 1, index).some((entry) => entry.verdict !== 'PASS')
|
|
394
|
+
if (nonEmpty(round.reopenReason) || reconstructedReopen) readyPass = round.at
|
|
395
|
+
}
|
|
396
|
+
return readyPass
|
|
397
|
+
}
|
|
398
|
+
const COMPONENT_PHASES = [
|
|
399
|
+
['unit-pass-to-complete-candidate', 'engineering/integration', 'component assembly'],
|
|
400
|
+
['complete-candidate-to-freeze', 'engineering/integration', 'integrated verification before freeze'],
|
|
401
|
+
['freeze-to-terminal-authorization', 'published CI', 'exact-head published CI and terminal authorization'],
|
|
402
|
+
['terminal-authorization-to-queue-request', 'queue', 'queue request'],
|
|
403
|
+
['queue-request-to-admission', 'queue', 'queue admission'],
|
|
404
|
+
['admission-to-merge', 'queue/CI', 'queue waiting and speculative queue CI'],
|
|
405
|
+
['merge-to-outcome-recorded', 'bookkeeping', 'delivery outcome recording'],
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
function latestAt(entries) {
|
|
409
|
+
const stamped = arr(entries).filter((entry) => nonEmpty(entry.at) && Number.isFinite(Date.parse(entry.at)))
|
|
410
|
+
return stamped.length ? stamped.reduce((latest, entry) => Date.parse(entry.at) > Date.parse(latest.at) ? entry : latest) : null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function componentCandidateClocks(component) {
|
|
414
|
+
const candidate = isObject(component?.candidate) ? component.candidate : {}
|
|
415
|
+
const generation = Number.isSafeInteger(candidate.generation) ? candidate.generation : null
|
|
416
|
+
const members = Array.isArray(component.memberRefs)
|
|
417
|
+
? component.memberRefs
|
|
418
|
+
: component.units.map((unit, index) => ({ index, id: unit.id, valid: true, resolved: true, duplicate: false, unit }))
|
|
419
|
+
const memberIds = members.map((member) => member.id)
|
|
420
|
+
const validMembership = members.length > 0 && members.every((member) => member.valid && member.resolved && !member.duplicate)
|
|
421
|
+
const updates = arr(candidate.updates).filter((update) => generation == null || update.generation === generation)
|
|
422
|
+
const completeUpdate = (validMembership && updates.find((update) => Array.isArray(update.includedUnits)
|
|
423
|
+
&& update.includedUnits.length === memberIds.length
|
|
424
|
+
&& memberIds.every((id) => update.includedUnits.includes(id)))) ?? null
|
|
425
|
+
const frozen = candidate.frozen?.generation === generation || generation == null ? candidate.frozen : null
|
|
426
|
+
const authorization = latestAt(arr(candidate.authorizations)
|
|
427
|
+
.filter((entry) => generation == null || entry.generation === generation))
|
|
428
|
+
return {
|
|
429
|
+
completeCandidateAt: nonEmpty(completeUpdate?.at) ? completeUpdate.at : null,
|
|
430
|
+
frozenAt: nonEmpty(frozen?.at) ? frozen.at : null,
|
|
431
|
+
authorizedAt: nonEmpty(authorization?.at) ? authorization.at : null,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function componentDeliveryClocks(component, members, authorizedAt) {
|
|
436
|
+
const validMembership = members.length > 0 && members.every((member) => member.valid !== false && member.resolved !== false && !member.duplicate)
|
|
437
|
+
const canonical = isObject(component?.delivery) ? component.delivery : null
|
|
438
|
+
if (canonical) {
|
|
439
|
+
const attempts = arr(canonical.attempts)
|
|
440
|
+
.filter((attempt) => attempt.generation === canonical.generation && nonEmpty(attempt.at) && Number.isFinite(Date.parse(attempt.at)))
|
|
441
|
+
.sort((left, right) => Date.parse(left.at) - Date.parse(right.at))
|
|
442
|
+
const observations = arr(canonical.observations)
|
|
443
|
+
.filter((observation) => observation.generation === canonical.generation && nonEmpty(observation.at) && Number.isFinite(Date.parse(observation.at)))
|
|
444
|
+
.sort((left, right) => Date.parse(left.at) - Date.parse(right.at))
|
|
445
|
+
const request = attempts.find((attempt) => attempt.action === 'enqueue') ?? null
|
|
446
|
+
const admitted = observations.find((observation) => !['not-enqueued', 'waiting-for-queue-conditions'].includes(observation.queueState)) ?? null
|
|
447
|
+
return {
|
|
448
|
+
queueRequestedAt: validMembership ? request?.at ?? null : null,
|
|
449
|
+
admittedAt: validMembership ? admitted?.at ?? null : null,
|
|
450
|
+
admissionStatus: validMembership && admitted ? 'measured' : 'unknown',
|
|
451
|
+
mergedAt: validMembership && canonical.outcome === 'merged' ? (canonical.mergedAt ?? canonical.outcomeAt ?? null) : null,
|
|
452
|
+
outcomeWrittenAt: validMembership && canonical.outcome === 'merged' ? canonical.outcomeAt ?? null : null,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const memberQueueEvidence = members.map((member) => {
|
|
456
|
+
const unit = member.unit
|
|
457
|
+
const attempts = arr(unit?.delivery?.attempts)
|
|
458
|
+
.filter((attempt) => nonEmpty(attempt.at) && Number.isFinite(Date.parse(attempt.at)))
|
|
459
|
+
.sort((left, right) => Date.parse(left.at) - Date.parse(right.at))
|
|
460
|
+
const afterAuthorization = nonEmpty(authorizedAt)
|
|
461
|
+
? attempts.filter((attempt) => Date.parse(attempt.at) >= Date.parse(authorizedAt))
|
|
462
|
+
: []
|
|
463
|
+
const request = afterAuthorization.find((attempt) => attempt.action === 'enqueue' && attempt.outcome === 'requested') ?? null
|
|
464
|
+
const waiting = afterAuthorization.filter((attempt) => attempt.action === 'enqueue' && attempt.outcome === 'waiting')
|
|
465
|
+
const admission = request == null ? null : waiting.find((attempt) => Date.parse(attempt.at) >= Date.parse(request.at)) ?? null
|
|
466
|
+
return {
|
|
467
|
+
queueRequestedAt: request?.at ?? null,
|
|
468
|
+
admittedAt: admission?.at ?? null,
|
|
469
|
+
admissionStatus: admission != null ? 'measured' : request != null && waiting.length ? 'out-of-order' : 'unknown',
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
const requested = memberQueueEvidence.map((evidence) => evidence.queueRequestedAt).filter(nonEmpty)
|
|
473
|
+
const admitted = memberQueueEvidence.map((evidence) => evidence.admittedAt).filter(nonEmpty)
|
|
474
|
+
const spans = members.map((member) => member.unit == null ? { delivered: null, outcomeWritten: null } : unitSpan(member.unit))
|
|
475
|
+
const merged = spans.map((span) => span.delivered).filter(nonEmpty)
|
|
476
|
+
const outcomeWritten = spans.map((span) => span.outcomeWritten).filter(nonEmpty)
|
|
477
|
+
return {
|
|
478
|
+
queueRequestedAt: validMembership && requested.length === memberQueueEvidence.length ? requested.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest) : null,
|
|
479
|
+
admittedAt: validMembership && admitted.length === memberQueueEvidence.length ? admitted.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest) : null,
|
|
480
|
+
admissionStatus: validMembership && admitted.length === memberQueueEvidence.length
|
|
481
|
+
? 'measured'
|
|
482
|
+
: validMembership && memberQueueEvidence.some((evidence) => evidence.admissionStatus === 'out-of-order') ? 'out-of-order' : 'unknown',
|
|
483
|
+
mergedAt: validMembership && merged.length === spans.length ? merged.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest) : null,
|
|
484
|
+
outcomeWrittenAt: validMembership && outcomeWritten.length === spans.length ? outcomeWritten.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest) : null,
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function componentPhase(id, category, label, fromAt, toAt, missingStatus = 'unknown') {
|
|
489
|
+
if (!nonEmpty(fromAt) || !nonEmpty(toAt)) {
|
|
490
|
+
return { id, category, label, fromAt: fromAt ?? null, toAt: toAt ?? null, durationMinutes: null, status: missingStatus }
|
|
491
|
+
}
|
|
492
|
+
const durationMs = ms(fromAt, toAt)
|
|
493
|
+
if (durationMs == null || durationMs < 0) {
|
|
494
|
+
return { id, category, label, fromAt, toAt, durationMinutes: null, status: 'out-of-order' }
|
|
495
|
+
}
|
|
496
|
+
return { id, category, label, fromAt, toAt, durationMinutes: mins(durationMs), status: 'measured' }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Stable machine-readable diagnostic for component lifecycle attribution. This uses only
|
|
500
|
+
// recorded clocks: a missing or backwards clock is named instead of being zeroed or inferred.
|
|
501
|
+
export function componentTimelinesFor(manifest = {}) {
|
|
502
|
+
const source = isObject(manifest) ? manifest : {}
|
|
503
|
+
return deliveryComponents(source).map((component) => {
|
|
504
|
+
const members = component.memberRefs ?? component.units.map((unit, index) => ({ index, id: unit.id, valid: true, resolved: true, duplicate: false, unit }))
|
|
505
|
+
const terminalPasses = members.map((member) => member.unit == null ? null : terminalUnitPass(member.unit))
|
|
506
|
+
const validMembership = members.length > 0
|
|
507
|
+
&& members.every((member) => member.valid !== false && member.resolved !== false && !member.duplicate)
|
|
508
|
+
const readyAt = validMembership && terminalPasses.length && terminalPasses.every(nonEmpty)
|
|
509
|
+
? terminalPasses.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest)
|
|
510
|
+
: null
|
|
511
|
+
const candidate = componentCandidateClocks(component)
|
|
512
|
+
const delivery = componentDeliveryClocks(component, members, candidate.authorizedAt)
|
|
513
|
+
const clocks = [readyAt, candidate.completeCandidateAt, candidate.frozenAt, candidate.authorizedAt,
|
|
514
|
+
delivery.queueRequestedAt, delivery.admittedAt, delivery.mergedAt, delivery.outcomeWrittenAt]
|
|
515
|
+
const phases = COMPONENT_PHASES.map(([id, category, label], index) => componentPhase(
|
|
516
|
+
id, category, label, clocks[index], clocks[index + 1],
|
|
517
|
+
id === 'queue-request-to-admission' ? delivery.admissionStatus : 'unknown',
|
|
518
|
+
))
|
|
519
|
+
const aggregate = componentPhase('component-ready-to-merge', 'aggregate', 'component-ready PASS to merge', readyAt, delivery.mergedAt)
|
|
520
|
+
const diagnostics = phases.filter((phase) => phase.status !== 'measured').map((phase) => ({
|
|
521
|
+
id: phase.status === 'out-of-order' ? 'out-of-order-clock' : 'missing-clock',
|
|
522
|
+
phase: phase.id,
|
|
523
|
+
detail: phase.status === 'out-of-order'
|
|
524
|
+
? nonEmpty(phase.fromAt) && nonEmpty(phase.toAt)
|
|
525
|
+
? `${phase.fromAt} is later than ${phase.toAt}; attribution is withheld.`
|
|
526
|
+
: 'Incompatible lifecycle clock ordering; attribution is withheld.'
|
|
527
|
+
: 'Missing lifecycle clock; attribution is unknown.',
|
|
528
|
+
}))
|
|
529
|
+
if (aggregate.status === 'out-of-order') diagnostics.push({
|
|
530
|
+
id: 'out-of-order-clock', phase: aggregate.id,
|
|
531
|
+
detail: `${aggregate.fromAt} is later than ${aggregate.toAt}; aggregate duration is withheld.`,
|
|
532
|
+
})
|
|
533
|
+
return {
|
|
534
|
+
id: component.id,
|
|
535
|
+
// `members` remains the legacy normalized-ID projection. `declaredMembers` is the
|
|
536
|
+
// complete diagnostic declaration, including invalid and duplicate slots.
|
|
537
|
+
members: members.map((member) => member.id).filter(nonEmpty),
|
|
538
|
+
declaredMembers: members.map(({ index, id, valid, resolved, duplicate }) => ({
|
|
539
|
+
index,
|
|
540
|
+
id: id ?? null,
|
|
541
|
+
valid: valid === true,
|
|
542
|
+
resolved: resolved === true,
|
|
543
|
+
duplicate: duplicate === true,
|
|
544
|
+
})),
|
|
545
|
+
aggregate: { startAt: aggregate.fromAt, endAt: aggregate.toAt, durationMinutes: aggregate.durationMinutes, status: aggregate.status },
|
|
546
|
+
phases,
|
|
547
|
+
diagnostics,
|
|
548
|
+
}
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function renderComponentTimelines(timelines) {
|
|
553
|
+
if (!timelines.length) return []
|
|
554
|
+
const rows = ['', ' COMPONENT TIMELINES']
|
|
555
|
+
for (const timeline of timelines) {
|
|
556
|
+
const memberSlots = timeline.declaredMembers.map((member) => {
|
|
557
|
+
const value = member.id ?? '<invalid>'
|
|
558
|
+
const status = !member.valid ? 'invalid'
|
|
559
|
+
: member.duplicate ? 'duplicate'
|
|
560
|
+
: !member.resolved ? 'missing' : 'resolved'
|
|
561
|
+
return `[${member.index}] ${value} (${status})`
|
|
562
|
+
}).join('; ')
|
|
563
|
+
rows.push(` ${timeline.id}: ${timeline.members.join(', ')} · aggregate ${timeline.aggregate.status === 'measured' ? fmt(timeline.aggregate.durationMinutes) : 'unknown'} · declared slots: ${memberSlots}`)
|
|
564
|
+
for (const phase of timeline.phases) {
|
|
565
|
+
const duration = phase.status === 'measured' ? fmt(phase.durationMinutes) : phase.status
|
|
566
|
+
rows.push(` ${phase.category}: ${phase.label} — ${duration}`)
|
|
567
|
+
}
|
|
568
|
+
for (const diagnostic of timeline.diagnostics) rows.push(` diagnostic [${diagnostic.id}] ${diagnostic.phase}: ${diagnostic.detail}`)
|
|
569
|
+
}
|
|
570
|
+
return rows
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function findDeliveryWait(m) {
|
|
574
|
+
// Delivery is component-shaped. A member of a co-delivery component is not independently
|
|
575
|
+
// ready at its own PASS, so charging every early member until the shared merge multiplies
|
|
576
|
+
// one integration interval by the component size (epic-892: eight findings for one wait).
|
|
577
|
+
// The component becomes ready at its latest member PASS. Singleton components retain the
|
|
578
|
+
// historical unit clock. Every member must carry a real delivery clock; partial delivery
|
|
579
|
+
// yields no timing claim rather than selecting whichever timestamp happens to exist.
|
|
580
|
+
const waits = deliveryComponents(m).map((component) => {
|
|
581
|
+
const members = component.memberRefs ?? component.units.map((unit, index) => ({ index, id: unit.id, valid: true, resolved: true, duplicate: false, unit }))
|
|
582
|
+
const validMembership = members.length > 0 && members.every((member) => member.valid !== false && member.resolved !== false && !member.duplicate)
|
|
583
|
+
const spans = members.map((member) => {
|
|
584
|
+
const unit = member.unit
|
|
585
|
+
if (unit == null) return { id: member.id, terminalPass: null, delivered: null }
|
|
586
|
+
const span = unitSpan(unit, m)
|
|
587
|
+
const rounds = arr(unit.rounds)
|
|
588
|
+
const firstPassIndex = rounds.findIndex((round) => round.verdict === 'PASS' && nonEmpty(round.at))
|
|
589
|
+
let readyPass = firstPassIndex >= 0 ? rounds[firstPassIndex].at : null
|
|
590
|
+
// A PASS is terminal unless a typed carve-out reopened it. Legacy logs may not carry
|
|
591
|
+
// reopenReason, but an intervening non-PASS verdict proves the unit re-entered the
|
|
592
|
+
// loop. Consecutive PASS rounds do not reset readiness: those are exactly the
|
|
593
|
+
// rounds-after-PASS waste this profiler reports elsewhere.
|
|
594
|
+
for (let index = firstPassIndex + 1; index >= 0 && index < rounds.length; index++) {
|
|
595
|
+
const round = rounds[index]
|
|
596
|
+
if (round.verdict !== 'PASS' || !nonEmpty(round.at)) continue
|
|
597
|
+
const interveningNonPass = rounds.slice(firstPassIndex + 1, index).some((entry) => entry.verdict !== 'PASS')
|
|
598
|
+
if (nonEmpty(round.reopenReason) || interveningNonPass) readyPass = round.at
|
|
599
|
+
}
|
|
600
|
+
return { id: unit.id, ...span, terminalPass: readyPass }
|
|
601
|
+
})
|
|
602
|
+
if (!validMembership || !spans.length || spans.some((span) => !nonEmpty(span.terminalPass) || !nonEmpty(span.delivered))) return null
|
|
603
|
+
const readyAt = spans.reduce((latest, span) => Date.parse(span.terminalPass) > Date.parse(latest) ? span.terminalPass : latest, spans[0].terminalPass)
|
|
604
|
+
const deliveredAt = spans.reduce((latest, span) => Date.parse(span.delivered) > Date.parse(latest) ? span.delivered : latest, spans[0].delivered)
|
|
605
|
+
const wait = mins(ms(readyAt, deliveredAt))
|
|
606
|
+
if (wait == null || wait <= 60) return null
|
|
607
|
+
return { id: component.id, members: spans.map((span) => span.id), readyAt, deliveredAt, wait }
|
|
608
|
+
}).filter(Boolean)
|
|
609
|
+
if (!waits.length) return null
|
|
610
|
+
waits.sort((a, b) => b.wait - a.wait)
|
|
611
|
+
const unitCount = waits.reduce((count, wait) => count + wait.members.length, 0)
|
|
612
|
+
return {
|
|
613
|
+
id: 'delivery-wait',
|
|
614
|
+
severity: 'medium',
|
|
615
|
+
costMinutes: waits[0].wait,
|
|
616
|
+
headline: `${waits.length} component(s) covering ${unitCount} unit(s) waited over an hour after component readiness`,
|
|
617
|
+
detail: 'Time from a component becoming ready (its latest member PASS) to its merge is integration, terminal review, CI repair, and queue time. It is one interval per deliverable, not one interval per member. Distinct from outcome-write-lag: that one is late bookkeeping after the PR already merged.',
|
|
618
|
+
evidence: waits.map((wait) => wait.members.length === 1
|
|
619
|
+
? `${wait.members[0]}: ${fmt(wait.wait)} from first PASS to delivery`
|
|
620
|
+
: `${wait.id}: ${fmt(wait.wait)} from component-ready PASS to delivery (${wait.members.length} units: ${wait.members.join(', ')})`),
|
|
621
|
+
recommendation: 'Split the component interval using candidate update/freeze/authorization and delivery timestamps where recorded: optimize assembly or integrated review before authorization, and queue mechanics after authorization. Revisit component boundaries only when symbol/assembly evidence says members were bundled unnecessarily; premise edges order delivery without bundling.',
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// #791 / issue-664 / identity#669: PR already MERGED (mergedAt recorded), but
|
|
626
|
+
// record-delivery-* was written hours later after a human progress ping. That lag is
|
|
627
|
+
// bookkeeping, not queue wait and not co-delivery hold. Stable id for the series index.
|
|
628
|
+
function findOutcomeWriteLag(m) {
|
|
629
|
+
const canonicalMembers = new Set()
|
|
630
|
+
const componentRows = arr(m.components).map((component) => {
|
|
631
|
+
const delivery = isObject(component.delivery) ? component.delivery : null
|
|
632
|
+
if (!delivery) return null
|
|
633
|
+
for (const id of Array.isArray(component.units) ? component.units : []) if (nonEmpty(id)) canonicalMembers.add(id)
|
|
634
|
+
if (delivery.outcome !== 'merged' || !nonEmpty(delivery.mergedAt) || !nonEmpty(delivery.outcomeAt)) return null
|
|
635
|
+
const lag = mins(ms(delivery.mergedAt, delivery.outcomeAt))
|
|
636
|
+
if (lag == null || lag <= 60) return null
|
|
637
|
+
const members = arr(m.units).filter((unit) => (Array.isArray(component.units) ? component.units : []).includes(unit.id))
|
|
638
|
+
const passes = members.map(terminalUnitPass).filter(nonEmpty)
|
|
639
|
+
const readyAt = passes.length === members.length && passes.length
|
|
640
|
+
? passes.reduce((latest, at) => Date.parse(at) > Date.parse(latest) ? at : latest)
|
|
641
|
+
: null
|
|
642
|
+
const realWait = readyAt ? mins(ms(readyAt, delivery.mergedAt)) : null
|
|
643
|
+
if (realWait != null && realWait > 60) return null
|
|
644
|
+
return { id: component.id, lag, mergedAt: delivery.mergedAt, written: delivery.outcomeAt }
|
|
645
|
+
}).filter(Boolean)
|
|
646
|
+
const legacyRows = arr(m.units).filter((unit) => !canonicalMembers.has(unit.id)).map((unit) => {
|
|
647
|
+
const span = unitSpan(unit, m)
|
|
648
|
+
if (!span.mergedAt || !span.outcomeWritten) return null
|
|
649
|
+
const lag = mins(ms(span.mergedAt, span.outcomeWritten))
|
|
650
|
+
if (lag == null || lag <= 60) return null
|
|
651
|
+
const realWait = span.firstPass && span.delivered ? mins(ms(span.firstPass, span.delivered)) : null
|
|
652
|
+
if (realWait != null && realWait > 60) return null
|
|
653
|
+
return { id: unit.id, lag, mergedAt: span.mergedAt, written: span.outcomeWritten }
|
|
654
|
+
}).filter(Boolean)
|
|
655
|
+
const lags = [...componentRows, ...legacyRows]
|
|
656
|
+
if (!lags.length) return null
|
|
657
|
+
lags.sort((a, b) => b.lag - a.lag)
|
|
658
|
+
return {
|
|
659
|
+
id: 'outcome-write-lag',
|
|
660
|
+
severity: 'low',
|
|
661
|
+
costMinutes: lags[0].lag,
|
|
662
|
+
headline: `${lags.length} unit(s) had delivery.outcome written long after the PR already merged`,
|
|
663
|
+
detail: 'The PR reached targetBranch (delivery.mergedAt) well before record-delivery-attempt/outcome ran. That gap is bookkeeping lag, not queue wait and not a co-delivery hold — typically hybrid/auto merge unobserved until a human asked for progress (issue-664 / identity#669).',
|
|
664
|
+
evidence: lags.map((w) => `${w.id}: mergedAt ${w.mergedAt} → outcome written ${w.written} (${fmt(w.lag)} lag)`),
|
|
665
|
+
recommendation: 'Launch the delivery watch in the same turn as PASS on enqueueTrigger ∈ {hybrid, auto}; do not wait for an explicit @mergifyio queue. On watch exit 4 with reason stall-while-queued, re-observe once (gh pr view) and relaunch the watch — do not leave the unit undelivered until a human asks for progress. Record mergedAt on record-delivery-outcome so the metric has a source of truth.',
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const FINDERS = [findRoundsAfterPass, findStaggeredStart, findCapBelowDefault, findUnrecordedPush, findDeliveryWait, findOutcomeWriteLag]
|
|
670
|
+
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 }
|
|
671
|
+
|
|
672
|
+
// The generic pass. Both orchestrators share the ranking, the renderer, the persistence and
|
|
673
|
+
// the cross-run series; what differs is WHICH questions get asked of the manifest and how
|
|
674
|
+
// the one-line summary reads. So the finder set and the summary are injected, and this
|
|
675
|
+
// function knows nothing about units or release steps.
|
|
676
|
+
//
|
|
677
|
+
// `metrics` is the per-kind headline block. It goes into the index line verbatim, which is
|
|
678
|
+
// what lets one series carry both kinds without either pretending to be the other — a
|
|
679
|
+
// release has no `inFlightCap` and a delivery run has no `step`, and forcing a shared column
|
|
680
|
+
// set would mean writing nulls that read as measurements.
|
|
681
|
+
export function profile(manifest = {}, opts = {}) {
|
|
682
|
+
const m = isObject(manifest) ? manifest : {}
|
|
683
|
+
const finders = Array.isArray(opts.finders) ? opts.finders : FINDERS
|
|
684
|
+
const summarise = typeof opts.summary === 'function' ? opts.summary : deliverySummary
|
|
685
|
+
const findings = finders.map((f) => f(m)).filter(Boolean)
|
|
686
|
+
.sort((a, b) => (SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])
|
|
687
|
+
|| ((b.costMinutes ?? 0) - (a.costMinutes ?? 0)))
|
|
688
|
+
const { kind, summaryLine, metrics, blindSpots } = summarise(m)
|
|
689
|
+
return { kind, runSlug: m.runSlug ?? null, summaryLine, metrics, blindSpots, findings }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function deliverySummary(m) {
|
|
693
|
+
const units = arr(m.units)
|
|
694
|
+
const metrics = {
|
|
695
|
+
units: units.length,
|
|
696
|
+
totalRounds: units.reduce((n, u) => n + arr(u.rounds).length, 0),
|
|
697
|
+
runMinutes: runMinutes(m),
|
|
698
|
+
inFlightCap: m.inFlightCap ?? DEFAULT_IN_FLIGHT_CAP,
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
kind: 'delivery',
|
|
702
|
+
metrics,
|
|
703
|
+
summaryLine: `${metrics.units} units · ${metrics.totalRounds} review rounds · ${fmt(metrics.runMinutes)} wall-clock · inFlightCap ${metrics.inFlightCap}`,
|
|
704
|
+
blindSpots: 'per-spawn wall-time, context growth, and orchestrator idle windows — read the session transcript for those',
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Kept as it was: the delivery pass's public entry point, and the shape its tests and the
|
|
709
|
+
// index have always seen. The metrics are spread flat here for exactly that reason.
|
|
710
|
+
export function postmortem(manifest = {}) {
|
|
711
|
+
const p = profile(manifest)
|
|
712
|
+
return { runSlug: p.runSlug, ...p.metrics, componentTimelines: componentTimelinesFor(manifest), findings: p.findings }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// A finding's cost, rendered for a human. Every finder but `findRoundsAfterPass` sets only
|
|
716
|
+
// `costMinutes`, and for those this renders exactly as before — `~5m` — so the common case
|
|
717
|
+
// does not get noisier to serve the rare one (#780). `costUnknown`/`demotedCostMinutes`/
|
|
718
|
+
// `demotedUnknown`/`demotedCount` only exist on `findRoundsAfterPass`'s output: when present,
|
|
719
|
+
// the excluded and demoted quantities are named explicitly, because #761's AC3 forbids an
|
|
720
|
+
// unpriceable round reading as priced, and that requirement was closed at `bucketRoundCost`
|
|
721
|
+
// (the data layer) while staying open here — the layer a human actually reads (#780).
|
|
722
|
+
// `!= null`, never truthiness: #761 made a genuine `costMinutes: 0` reachable, and
|
|
723
|
+
// `f.costMinutes ? … : ''` rendered a real zero byte-identical to an unpriced finding.
|
|
724
|
+
//
|
|
725
|
+
// `costMinutes: null` is ambiguous on its own — it means either "some undemoted rounds
|
|
726
|
+
// exist but none could be timed" (costUnknown > 0) or "there are no undemoted rounds at
|
|
727
|
+
// all, e.g. every hit was demoted" (costUnknown === 0, since `bucketRoundCost` only ever
|
|
728
|
+
// increments it for an undemoted hit). Only the first is genuinely unpriceable; the second
|
|
729
|
+
// has nothing to report in the ranked bucket, and saying "unpriceable" there would claim a
|
|
730
|
+
// failure that never happened — epic-563's fixture is exactly this case (0 undemoted hits,
|
|
731
|
+
// 3 demoted), and the ranked bucket should say nothing while the demoted one still does.
|
|
732
|
+
function formatCost(f) {
|
|
733
|
+
const unknown = f.costUnknown || 0
|
|
734
|
+
const demotedCount = f.demotedCount || 0
|
|
735
|
+
if (!unknown && !demotedCount) return f.costMinutes != null ? `~${fmt(f.costMinutes)}` : null
|
|
736
|
+
const ranked = []
|
|
737
|
+
if (f.costMinutes != null) ranked.push(`~${fmt(f.costMinutes)} ranked`)
|
|
738
|
+
if (unknown) ranked.push(`${unknown} unpriceable`)
|
|
739
|
+
const parts = ranked.length ? [ranked.join(', ')] : []
|
|
740
|
+
if (demotedCount) {
|
|
741
|
+
// Symmetric with the ranked branch above, and for the same reason: a demoted round can
|
|
742
|
+
// be PARTIALLY unpriceable — `demotedCostMinutes` is the sum of the ones that could be
|
|
743
|
+
// timed, `demotedUnknown` is how many could not (0 when every demoted round priced, ===
|
|
744
|
+
// demotedCount when none did — that all-unpriceable case is the `null` branch below).
|
|
745
|
+
// Reading `demotedCostMinutes` whole without naming `demotedUnknown` reproduces #780's
|
|
746
|
+
// own opening defect one bucket over: a subtotal renders as if it covered everything it
|
|
747
|
+
// was computed from, when part of what it was computed from was never priced at all.
|
|
748
|
+
const demotedUnknown = f.demotedUnknown || 0
|
|
749
|
+
parts.push(f.demotedCostMinutes != null
|
|
750
|
+
? (demotedUnknown ? `~${fmt(f.demotedCostMinutes)} demoted, ${demotedUnknown} unpriceable` : `~${fmt(f.demotedCostMinutes)} demoted`)
|
|
751
|
+
: 'demoted cost unpriceable')
|
|
752
|
+
}
|
|
753
|
+
// `parts` is never empty here: the guard above already returned for `!unknown &&
|
|
754
|
+
// !demotedCount`, and every remaining path pushes at least one entry — `unknown` truthy
|
|
755
|
+
// always pushes into `ranked` regardless of `costMinutes`, and `demotedCount` truthy
|
|
756
|
+
// always pushes its own entry above.
|
|
757
|
+
return parts.join('; ')
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function renderProfile(p) {
|
|
761
|
+
const rows = [
|
|
762
|
+
`POST-MORTEM — ${p.runSlug ?? '(unnamed run)'}`,
|
|
763
|
+
` ${p.summaryLine}`,
|
|
764
|
+
'',
|
|
765
|
+
]
|
|
766
|
+
if (!p.findings.length) {
|
|
767
|
+
rows.push(' No findings. The record holds nothing this pass knows how to fault —')
|
|
768
|
+
rows.push(' which is a statement about THIS pass, not a clean bill of health: it reads')
|
|
769
|
+
rows.push(` ${p.source ?? 'the run manifest'} only, so it cannot see ${p.blindSpots}.`)
|
|
770
|
+
return `${rows.join('\n')}\n`
|
|
771
|
+
}
|
|
772
|
+
for (const f of p.findings) {
|
|
773
|
+
const cost = formatCost(f)
|
|
774
|
+
rows.push(` [${f.severity.toUpperCase()}] ${f.headline}${cost ? ` (${cost})` : ''}`)
|
|
775
|
+
rows.push(` ${f.detail}`)
|
|
776
|
+
for (const e of f.evidence) rows.push(` · ${e}`)
|
|
777
|
+
rows.push(` → ${f.recommendation}`)
|
|
778
|
+
rows.push('')
|
|
779
|
+
}
|
|
780
|
+
rows.push(` Computed from ${p.source ?? 'the run manifest'}. What it cannot see: ${p.blindSpots}.`)
|
|
781
|
+
return `${rows.join('\n')}\n`
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function renderPostmortem(manifest = {}) {
|
|
785
|
+
const p = profile(manifest)
|
|
786
|
+
const base = renderProfile(p).trimEnd().split('\n')
|
|
787
|
+
base.push(...renderComponentTimelines(componentTimelinesFor(manifest)))
|
|
788
|
+
return `${base.join('\n')}\n`
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The issue body. The post-mortem's output is worth nothing if it lands in a handoff
|
|
792
|
+
// nobody re-reads, so the step ends by filing it — one issue per run, against the skill,
|
|
793
|
+
// with the measurements inline. Rendered here rather than composed by the caller for the
|
|
794
|
+
// same reason the spawn contract is: a composed one decays across runs.
|
|
795
|
+
export function renderProfileIssue(p, skillName = 'resolve-issues') {
|
|
796
|
+
const title = `${skillName} post-mortem: ${p.runSlug ?? 'run'} (${p.summaryLine.split(' · ').slice(0, 2).join(', ')}, ${p.findings.length} findings)`
|
|
797
|
+
const body = [
|
|
798
|
+
`Automated post-mortem of \`${p.runSlug ?? '(unnamed run)'}\`. Every number below is computed from the run manifest by \`${skillName}/scripts/postmortem.mjs\`; none of it is recalled.`,
|
|
799
|
+
'',
|
|
800
|
+
`**Run:** ${p.summaryLine}`,
|
|
801
|
+
'',
|
|
802
|
+
...(p.findings.length ? p.findings.flatMap((f) => {
|
|
803
|
+
const cost = formatCost(f)
|
|
804
|
+
return [
|
|
805
|
+
`### ${f.headline}`,
|
|
806
|
+
'',
|
|
807
|
+
`**Severity:** ${f.severity}${cost ? ` · **measured cost:** ${cost}` : ''}`,
|
|
808
|
+
'',
|
|
809
|
+
f.detail,
|
|
810
|
+
'',
|
|
811
|
+
...f.evidence.map((e) => `- ${e}`),
|
|
812
|
+
'',
|
|
813
|
+
`**Proposed change:** ${f.recommendation}`,
|
|
814
|
+
'',
|
|
815
|
+
]
|
|
816
|
+
}) : ['No findings — the manifest holds nothing this pass knows how to fault.', '']),
|
|
817
|
+
'---',
|
|
818
|
+
'',
|
|
819
|
+
`Scope note: this pass reads the run manifest only, so it cannot see ${p.blindSpots}. A finding it did *not* raise is not evidence those were fine.`,
|
|
820
|
+
].join('\n')
|
|
821
|
+
return { title, body }
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export function renderPostmortemIssue(manifest = {}) {
|
|
825
|
+
const rendered = renderProfileIssue(profile(manifest))
|
|
826
|
+
const timelines = componentTimelinesFor(manifest)
|
|
827
|
+
if (!timelines.length) return rendered
|
|
828
|
+
return {
|
|
829
|
+
...rendered,
|
|
830
|
+
body: `${rendered.body}\n\n## Component Timelines\n\n${renderComponentTimelines(timelines).map((line) => line.trim()).join('\n')}`,
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---- persistence ----
|
|
835
|
+
//
|
|
836
|
+
// Three artifacts, because three different readers want this and none of them wants the
|
|
837
|
+
// other's format:
|
|
838
|
+
//
|
|
839
|
+
// <slug>.json the structured profile — what a later diff or a script reads
|
|
840
|
+
// <slug>.txt the rendered report — what a human opens six weeks later
|
|
841
|
+
// index.jsonl one line per save — the CROSS-RUN series, which is the whole point
|
|
842
|
+
//
|
|
843
|
+
// The index is APPEND-ONLY and deliberately not deduplicated. Re-running the pass on the
|
|
844
|
+
// same slug appends a second line, and that is a fact worth keeping (it dates the re-read),
|
|
845
|
+
// not a bug to suppress. Readers take the last entry per slug. Append rather than
|
|
846
|
+
// read-modify-write for the reason run-state.mjs already documents about `writeManifest`:
|
|
847
|
+
// nothing guards a read-modify-write here either, and two runs finishing together would
|
|
848
|
+
// silently drop one. A single `appendFileSync` of one line does not have that problem.
|
|
849
|
+
//
|
|
850
|
+
// NEVER THROWS. A failed save must not take the handoff down with it — the profile is a
|
|
851
|
+
// diagnostic, and a read-only or full disk is a reason to report a degraded save, not to
|
|
852
|
+
// fail a run whose units all merged. Returns { saved, paths, error }.
|
|
853
|
+
// Takes a PROFILE, not a manifest, so both orchestrators write one series in one format.
|
|
854
|
+
// The index line carries `kind` plus the per-kind `metrics` block rather than a flattened
|
|
855
|
+
// union of both schemas: a release has no `inFlightCap` and a delivery run has no `step`,
|
|
856
|
+
// and a shared column set would mean writing nulls that read as measurements.
|
|
857
|
+
export function saveProfile(p, env = process.env, now = new Date()) {
|
|
858
|
+
const paths = postmortemPaths(p?.runSlug, env)
|
|
859
|
+
const savedAt = now.toISOString()
|
|
860
|
+
try {
|
|
861
|
+
fs.mkdirSync(paths.dir, { recursive: true })
|
|
862
|
+
fs.writeFileSync(paths.json, `${JSON.stringify({ ...p, savedAt }, null, 2)}\n`)
|
|
863
|
+
fs.writeFileSync(paths.text, renderProfile(p))
|
|
864
|
+
// Narrow on purpose — the full findings live in the JSON, and narrowness is what keeps
|
|
865
|
+
// the series greppable at 200 runs. `costUnknown`/`demotedCostMinutes`/`demotedUnknown`
|
|
866
|
+
// join `costMinutes` here for the same reason they now reach the renderers (#780): a
|
|
867
|
+
// trend series that carries the ranked cost but drops what was excluded from it would
|
|
868
|
+
// let the same "excluded reads as priced" defect creep back into a longitudinal read,
|
|
869
|
+
// even though `renderProfile`/`renderProfileIssue` now say so per-run. Still numeric
|
|
870
|
+
// fields, still no prose — the "stay narrow" invariant the assertion below checks is
|
|
871
|
+
// about `detail`/`evidence`, not about the count of numeric columns.
|
|
872
|
+
fs.appendFileSync(paths.index, `${JSON.stringify({
|
|
873
|
+
savedAt,
|
|
874
|
+
kind: p.kind ?? 'delivery',
|
|
875
|
+
runSlug: p.runSlug,
|
|
876
|
+
metrics: p.metrics ?? {},
|
|
877
|
+
findings: (p.findings ?? []).map((f) => ({
|
|
878
|
+
id: f.id,
|
|
879
|
+
severity: f.severity,
|
|
880
|
+
costMinutes: f.costMinutes ?? null,
|
|
881
|
+
costUnknown: f.costUnknown ?? 0,
|
|
882
|
+
demotedCostMinutes: f.demotedCostMinutes ?? null,
|
|
883
|
+
demotedUnknown: f.demotedUnknown ?? 0,
|
|
884
|
+
// `demotedCount` is the denominator `demotedUnknown` is a count OUT OF — the
|
|
885
|
+
// renderers use this same pair (formatCost reads both) to tell "no demoted rounds
|
|
886
|
+
// at all" (demotedCount: 0) from "some demoted rounds, none unpriceable"
|
|
887
|
+
// (demotedCount > 0, demotedUnknown: 0) apart. Carrying demotedUnknown without it
|
|
888
|
+
// is an excluded count with no denominator.
|
|
889
|
+
demotedCount: f.demotedCount ?? 0,
|
|
890
|
+
})),
|
|
891
|
+
})}\n`)
|
|
892
|
+
return { saved: true, paths, error: null }
|
|
893
|
+
} catch (e) {
|
|
894
|
+
return { saved: false, paths, error: e?.message ?? String(e) }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export function savePostmortem(manifest = {}, env = process.env, now = new Date()) {
|
|
899
|
+
return saveProfile(profile(manifest), env, now)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Is every unit terminal enough for completion-phase reporting? This remains a pure
|
|
903
|
+
// predicate: progress and next may read it, but only the dedicated finalize transition
|
|
904
|
+
// can authorize completion. The explicit postmortem diagnostic is also read-only.
|
|
905
|
+
export function runIsComplete(manifest = {}) {
|
|
906
|
+
const units = arr(manifest?.units)
|
|
907
|
+
if (!units.length) return false
|
|
908
|
+
return units.every((unit) => terminalDisposition(unit) !== null)
|
|
909
|
+
}
|