@fusengine/harness 0.1.90 → 0.1.91
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/dist/adapters/claude/index.d.mts +1 -1
- package/dist/adapters/claude/index.mjs +1 -1
- package/dist/adapters/cline/index.mjs +1 -1
- package/dist/adapters/codex/index.d.mts +2 -2
- package/dist/adapters/codex/index.mjs +3 -2
- package/dist/adapters/cursor/index.d.mts +15 -28
- package/dist/adapters/cursor/index.mjs +35 -46
- package/dist/adapters/gemini/index.mjs +1 -1
- package/dist/adapters/hermes/index.d.mts +1 -1
- package/dist/adapters/hermes/index.mjs +1 -1
- package/dist/adapters/kimi/index.d.mts +1 -1
- package/dist/adapters/kimi/index.mjs +1 -1
- package/dist/{claude-5bC8TDAz.mjs → claude-Ckv2_TgP.mjs} +3 -43
- package/dist/cli/bin.mjs +429 -16
- package/dist/cli/index.mjs +1 -1
- package/dist/command-string-CALMTnwN.mjs +42 -0
- package/dist/{evaluate-C1VSI5pb.mjs → evaluate-CMiIqHeH.mjs} +261 -13
- package/dist/{handle-CBaOcFZq.mjs → handle-C43gA-Pr.mjs} +701 -134
- package/dist/{hermes-kP6NUQlG.mjs → hermes-B9-p_3IF.mjs} +3 -2
- package/dist/{index-DBgnIZn9.d.mts → index-B8PG82su.d.mts} +1 -1
- package/dist/{index-D4C2-IIc.d.mts → index-SmKYkk2N.d.mts} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/init/index.d.mts +1 -1
- package/dist/init/index.mjs +1 -1
- package/dist/{kimi-D6X7b8wF.mjs → kimi-G2wcSh5-.mjs} +3 -2
- package/dist/normalize-BjG6unTj.mjs +317 -0
- package/dist/policy/index.d.mts +1 -1
- package/dist/policy/index.mjs +2 -2
- package/dist/prompt/index.d.mts +1 -1
- package/dist/{run-CdIlUtjv.mjs → run-DkrzC0gb.mjs} +1 -1
- package/dist/{run-TAXNRSpD.mjs → run-GHAMqcOn.mjs} +26 -3
- package/dist/runtime/index.d.mts +27 -16
- package/dist/runtime/index.mjs +2 -2
- package/dist/{types-C0-igRKr.d.mts → types-Bh0jEF3_.d.mts} +2 -0
- package/dist/{validate-DZFtAbLP.mjs → validate-KjZ1X9tH.mjs} +1 -1
- package/package.json +3 -2
- package/src/adapters/claude/index.ts +92 -0
- package/src/adapters/cline/index.ts +36 -0
- package/src/adapters/codex/apply-patch.ts +94 -0
- package/src/adapters/codex/index.ts +84 -0
- package/src/adapters/codex/permission-mode.ts +16 -0
- package/src/adapters/cursor/context.ts +46 -0
- package/src/adapters/cursor/events.ts +38 -0
- package/src/adapters/cursor/index.ts +43 -0
- package/src/adapters/cursor/interfaces/types.ts +88 -0
- package/src/adapters/cursor/native-response.ts +158 -0
- package/src/adapters/cursor/normalize.ts +123 -0
- package/src/adapters/cursor/respond.ts +123 -0
- package/src/adapters/gemini/index.ts +36 -0
- package/src/adapters/hermes/index.ts +55 -0
- package/src/adapters/hermes/interfaces/types.ts +26 -0
- package/src/adapters/kimi/index.ts +96 -0
- package/src/adapters/kimi/interfaces/types.ts +55 -0
- package/src/cache/compact.ts +55 -0
- package/src/cache/index.ts +5 -0
- package/src/cache/io.ts +38 -0
- package/src/cache/mcp-response.ts +39 -0
- package/src/cache/mcp-store.ts +70 -0
- package/src/cache/store.ts +93 -0
- package/src/changelog/fetch.ts +79 -0
- package/src/cli/bin.ts +100 -0
- package/src/cli/cursor-event-scanner.ts +175 -0
- package/src/cli/cursor-stdin-reader.ts +65 -0
- package/src/cli/doctor.ts +96 -0
- package/src/cli/hook-io.ts +130 -0
- package/src/cli/hook-sound.ts +49 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/json-primitive-scanner.ts +83 -0
- package/src/cli/run.ts +36 -0
- package/src/cli/scope.ts +31 -0
- package/src/codex-rules/build.ts +63 -0
- package/src/codex-rules/index.ts +8 -0
- package/src/codex-rules/mapping.ts +42 -0
- package/src/codex-rules/rules/absolute-paths.ts +52 -0
- package/src/codex-rules/rules/ask.ts +44 -0
- package/src/codex-rules/rules/chmod-ask.ts +57 -0
- package/src/codex-rules/rules/dd.ts +23 -0
- package/src/codex-rules/rules/diskutil.ts +33 -0
- package/src/codex-rules/rules/mkfs.ts +42 -0
- package/src/codex-rules/rules/privilege.ts +49 -0
- package/src/codex-rules/rules/remote-fetch.ts +31 -0
- package/src/codex-rules/rules/rm-variants.ts +58 -0
- package/src/codex-rules/rules/single-commands.ts +57 -0
- package/src/codex-rules/skip-list.ts +25 -0
- package/src/codex-rules/starlark.ts +46 -0
- package/src/codex-rules/types.ts +36 -0
- package/src/config/dotenv.ts +55 -0
- package/src/config/env.ts +10 -0
- package/src/config/home-dir.ts +36 -0
- package/src/config/index.ts +5 -0
- package/src/config/layout.ts +52 -0
- package/src/config/limits.ts +56 -0
- package/src/config/ttl.ts +30 -0
- package/src/detect/harness.ts +73 -0
- package/src/detect/index.ts +1 -0
- package/src/detect/interfaces/types.ts +18 -0
- package/src/freshness/agent-evidence-record.ts +95 -0
- package/src/freshness/agent-evidence.ts +95 -0
- package/src/freshness/codex-spawn-evidence.ts +91 -0
- package/src/freshness/doc-helpers.ts +90 -0
- package/src/freshness/evidence-harvest-io.ts +72 -0
- package/src/freshness/evidence-harvest.ts +61 -0
- package/src/freshness/explore-tools.ts +95 -0
- package/src/freshness/index.ts +2 -0
- package/src/freshness/query-framework.ts +73 -0
- package/src/freshness/ref-evidence.ts +49 -0
- package/src/freshness/ref-journal.ts +98 -0
- package/src/freshness/trivial-edit-counter.ts +30 -0
- package/src/index.ts +11 -0
- package/src/init/index.ts +2 -0
- package/src/init/run.ts +32 -0
- package/src/init/templates.ts +75 -0
- package/src/memory/gitignore.ts +14 -0
- package/src/memory/index.ts +3 -0
- package/src/memory/registry.ts +39 -0
- package/src/memory/session-roots.ts +99 -0
- package/src/memory/state.ts +58 -0
- package/src/policy/apex-authorization.ts +100 -0
- package/src/policy/apex-gates.ts +91 -0
- package/src/policy/apex-target-steps.ts +73 -0
- package/src/policy/apex-target.ts +49 -0
- package/src/policy/apex-task-context.ts +81 -0
- package/src/policy/apex.ts +94 -0
- package/src/policy/cartographer/build-tree.ts +69 -0
- package/src/policy/cartographer/describe.ts +53 -0
- package/src/policy/cartographer/entry.ts +38 -0
- package/src/policy/cartographer/frontmatter.ts +48 -0
- package/src/policy/cartographer/indicators.ts +34 -0
- package/src/policy/claude-md-context.ts +66 -0
- package/src/policy/conventions/index.ts +11 -0
- package/src/policy/conventions/interfaces.ts +71 -0
- package/src/policy/conventions/langs.ts +53 -0
- package/src/policy/conventions/query.ts +49 -0
- package/src/policy/conventions/react-hooks.ts +18 -0
- package/src/policy/conventions/store-or-query.ts +21 -0
- package/src/policy/conventions/stores.ts +56 -0
- package/src/policy/conventions/strip.ts +87 -0
- package/src/policy/conventions/verdict.ts +38 -0
- package/src/policy/creation-intent.ts +11 -0
- package/src/policy/deny-loop.ts +88 -0
- package/src/policy/design/allowed-write.ts +28 -0
- package/src/policy/design/content-checks.ts +45 -0
- package/src/policy/design/corpus-fs.ts +21 -0
- package/src/policy/design/corpus-resolve-cache.ts +49 -0
- package/src/policy/design/corpus-resolve.ts +99 -0
- package/src/policy/design/corpus.ts +92 -0
- package/src/policy/design/design-system-rules.ts +45 -0
- package/src/policy/design/flag.ts +30 -0
- package/src/policy/design/gates-pipeline.ts +105 -0
- package/src/policy/design/gates.ts +87 -0
- package/src/policy/design/screenshot-tools.ts +24 -0
- package/src/policy/design/skill-evidence.ts +34 -0
- package/src/policy/design/skill-gate.ts +81 -0
- package/src/policy/design/skill-triggers.ts +51 -0
- package/src/policy/design/state.ts +92 -0
- package/src/policy/design/template-urls.ts +54 -0
- package/src/policy/design/transitions.ts +68 -0
- package/src/policy/detect-claude-md-project-type.ts +32 -0
- package/src/policy/detect-framework.ts +99 -0
- package/src/policy/detect-primitive-checks.ts +100 -0
- package/src/policy/detect-primitive-lib.ts +55 -0
- package/src/policy/detect-project.ts +99 -0
- package/src/policy/edit-outcome.ts +53 -0
- package/src/policy/evaluate.ts +81 -0
- package/src/policy/expert-agents.ts +60 -0
- package/src/policy/file-size-scope.ts +32 -0
- package/src/policy/file-size.ts +99 -0
- package/src/policy/framework-solid-exclude.ts +52 -0
- package/src/policy/framework-solid-extended.ts +93 -0
- package/src/policy/framework-solid-gates-systems.ts +99 -0
- package/src/policy/framework-solid-gates.ts +98 -0
- package/src/policy/framework-solid.ts +88 -0
- package/src/policy/gemini-mcp-gate.ts +83 -0
- package/src/policy/git-gates.ts +40 -0
- package/src/policy/guards/bash-command-anchor.ts +40 -0
- package/src/policy/guards/bash-write-patterns.ts +140 -0
- package/src/policy/guards/bash-write-redirects.ts +201 -0
- package/src/policy/guards/bash-write-safe-paths.ts +62 -0
- package/src/policy/guards/bash-write.ts +76 -0
- package/src/policy/guards/context.ts +12 -0
- package/src/policy/guards/index.ts +61 -0
- package/src/policy/guards/install.ts +22 -0
- package/src/policy/guards/interface-separation-ext.ts +41 -0
- package/src/policy/guards/interface-separation.ts +100 -0
- package/src/policy/guards/protected-path.ts +100 -0
- package/src/policy/guards/security.ts +97 -0
- package/src/policy/index.ts +19 -0
- package/src/policy/interfaces/types.ts +46 -0
- package/src/policy/js-gate-route.ts +51 -0
- package/src/policy/lessons/lesson-gate.ts +0 -0
- package/src/policy/lessons/trigger-index.ts +84 -0
- package/src/policy/lessons/types.ts +31 -0
- package/src/policy/module-layout.ts +31 -0
- package/src/policy/nearest-manifest.ts +75 -0
- package/src/policy/never-approval.ts +95 -0
- package/src/policy/patterns.ts +80 -0
- package/src/policy/seo/validate.ts +51 -0
- package/src/policy/shadcn-project.ts +22 -0
- package/src/policy/shadcn-skill-gate.ts +97 -0
- package/src/policy/shell-read-refs.ts +96 -0
- package/src/policy/skill-path.ts +46 -0
- package/src/policy/skill-patterns/laravel.ts +40 -0
- package/src/policy/skill-patterns/nextjs.ts +29 -0
- package/src/policy/skill-patterns/react.ts +27 -0
- package/src/policy/skill-patterns/shadcn-subskills.ts +33 -0
- package/src/policy/skill-patterns/shadcn.ts +66 -0
- package/src/policy/skill-patterns/swift.ts +30 -0
- package/src/policy/skill-patterns/tailwind.ts +40 -0
- package/src/policy/skill-source.ts +52 -0
- package/src/policy/skill-trigger-patterns.ts +33 -0
- package/src/policy/skill-triggers.ts +108 -0
- package/src/policy/tailwind-skill-gate.ts +63 -0
- package/src/policy/trivial-edits.ts +52 -0
- package/src/policy/verbosity.ts +39 -0
- package/src/prompt/index.ts +1 -0
- package/src/prompt/types.ts +36 -0
- package/src/refs/discover.ts +69 -0
- package/src/refs/frontmatter.ts +24 -0
- package/src/refs/index.ts +4 -0
- package/src/refs/loader.ts +59 -0
- package/src/refs/ref-key.ts +41 -0
- package/src/refs/router.ts +63 -0
- package/src/refs/types.ts +25 -0
- package/src/runtime/activity.ts +99 -0
- package/src/runtime/apply-patch-gate.ts +41 -0
- package/src/runtime/burst-window.ts +21 -0
- package/src/runtime/codex-shell-tool.ts +37 -0
- package/src/runtime/command-string.ts +37 -0
- package/src/runtime/confirm/codex-confirm.ts +126 -0
- package/src/runtime/confirm/codex-prompt-origin.ts +16 -0
- package/src/runtime/confirm/confirm-code.ts +33 -0
- package/src/runtime/confirm/confirm-gate.ts +65 -0
- package/src/runtime/confirm/confirm-irreversible.ts +32 -0
- package/src/runtime/confirm/confirm-pending.ts +34 -0
- package/src/runtime/confirm/confirm-state.ts +56 -0
- package/src/runtime/confirm/confirm-subagent.ts +112 -0
- package/src/runtime/confirm/confirm-submit.ts +38 -0
- package/src/runtime/core-guards-active.ts +84 -0
- package/src/runtime/core-guards-wired.ts +47 -0
- package/src/runtime/deny-loop-store.ts +94 -0
- package/src/runtime/deny-notice.ts +52 -0
- package/src/runtime/design-cache-resolve.ts +51 -0
- package/src/runtime/design-content-gate.ts +95 -0
- package/src/runtime/design-files-gate.ts +114 -0
- package/src/runtime/design-helpers.ts +93 -0
- package/src/runtime/design-lifecycle.ts +39 -0
- package/src/runtime/design-read-credit.ts +28 -0
- package/src/runtime/design.ts +116 -0
- package/src/runtime/dev-context.ts +43 -0
- package/src/runtime/dry-patterns.ts +32 -0
- package/src/runtime/dry.ts +96 -0
- package/src/runtime/fragment-registry.ts +33 -0
- package/src/runtime/framework-skill-gate.ts +79 -0
- package/src/runtime/fs-cleanup.ts +71 -0
- package/src/runtime/gate-apex.ts +81 -0
- package/src/runtime/gate-helpers.ts +52 -0
- package/src/runtime/gate-input.ts +26 -0
- package/src/runtime/gate.ts +128 -0
- package/src/runtime/handle-post.ts +104 -0
- package/src/runtime/handle-pre.ts +137 -0
- package/src/runtime/handle-scope-async.ts +29 -0
- package/src/runtime/handle-types.ts +27 -0
- package/src/runtime/handle.ts +116 -0
- package/src/runtime/home-state.ts +55 -0
- package/src/runtime/index.ts +18 -0
- package/src/runtime/inform.ts +28 -0
- package/src/runtime/inject-budget-recap.ts +47 -0
- package/src/runtime/inject-budget.ts +81 -0
- package/src/runtime/inject-context.ts +85 -0
- package/src/runtime/inject-dedup-exclusive.ts +98 -0
- package/src/runtime/inject-dedup.ts +89 -0
- package/src/runtime/is-agent-tool.ts +19 -0
- package/src/runtime/lifecycle/agent-files.ts +49 -0
- package/src/runtime/lifecycle/agent-memory.ts +82 -0
- package/src/runtime/lifecycle/agent-transcript.ts +80 -0
- package/src/runtime/lifecycle/aipilot/analytics.ts +99 -0
- package/src/runtime/lifecycle/aipilot/apex-task-store.ts +61 -0
- package/src/runtime/lifecycle/aipilot/cache-base.ts +48 -0
- package/src/runtime/lifecycle/aipilot/cache-doc.ts +100 -0
- package/src/runtime/lifecycle/aipilot/cache-lessons.ts +58 -0
- package/src/runtime/lifecycle/aipilot/cache-test.ts +70 -0
- package/src/runtime/lifecycle/aipilot/curate-lessons.ts +64 -0
- package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +100 -0
- package/src/runtime/lifecycle/aipilot/doc-cache-gate.ts +69 -0
- package/src/runtime/lifecycle/aipilot/inject-apex.ts +98 -0
- package/src/runtime/lifecycle/aipilot/inject-doc.ts +62 -0
- package/src/runtime/lifecycle/aipilot/inject-explore.ts +64 -0
- package/src/runtime/lifecycle/aipilot/inject-lessons.ts +61 -0
- package/src/runtime/lifecycle/aipilot/inject-test.ts +48 -0
- package/src/runtime/lifecycle/aipilot/lesson-archive.ts +60 -0
- package/src/runtime/lifecycle/aipilot/lesson-inject.ts +98 -0
- package/src/runtime/lifecycle/aipilot/lesson-parse.ts +74 -0
- package/src/runtime/lifecycle/aipilot/lessons.ts +72 -0
- package/src/runtime/lifecycle/aipilot/promote-global-lessons.ts +92 -0
- package/src/runtime/lifecycle/aipilot/solid-compliance.ts +69 -0
- package/src/runtime/lifecycle/aipilot/solid-notice.ts +47 -0
- package/src/runtime/lifecycle/aipilot/solid-transcript.ts +60 -0
- package/src/runtime/lifecycle/aipilot/source-scan.ts +56 -0
- package/src/runtime/lifecycle/aipilot/sync-task.ts +75 -0
- package/src/runtime/lifecycle/aipilot/transcript.ts +88 -0
- package/src/runtime/lifecycle/aipilot/types.ts +72 -0
- package/src/runtime/lifecycle/auto-document-reads.ts +73 -0
- package/src/runtime/lifecycle/cartographer/detect.ts +57 -0
- package/src/runtime/lifecycle/cartographer/ecosystem-map.ts +56 -0
- package/src/runtime/lifecycle/cartographer/fs-util.ts +64 -0
- package/src/runtime/lifecycle/cartographer/merge.ts +51 -0
- package/src/runtime/lifecycle/cartographer/project-map.ts +49 -0
- package/src/runtime/lifecycle/cartographer/scan-hooks.ts +48 -0
- package/src/runtime/lifecycle/cartographer/scan.ts +81 -0
- package/src/runtime/lifecycle/cartographer/session-start.ts +33 -0
- package/src/runtime/lifecycle/cartographer/track-enrichment.ts +45 -0
- package/src/runtime/lifecycle/cartographer/write-plugin-map.ts +43 -0
- package/src/runtime/lifecycle/cartographer/write-tree.ts +44 -0
- package/src/runtime/lifecycle/changelog-research.ts +43 -0
- package/src/runtime/lifecycle/check-file-size.ts +61 -0
- package/src/runtime/lifecycle/codex-resync/fingerprint.ts +63 -0
- package/src/runtime/lifecycle/codex-resync/lock.ts +46 -0
- package/src/runtime/lifecycle/codex-resync/managed-destination.ts +39 -0
- package/src/runtime/lifecycle/codex-resync/materialize-agents.ts +57 -0
- package/src/runtime/lifecycle/codex-resync/plugin-files.ts +65 -0
- package/src/runtime/lifecycle/codex-resync/plugin-roots.ts +62 -0
- package/src/runtime/lifecycle/codex-resync/resync.ts +45 -0
- package/src/runtime/lifecycle/codex-resync/symlink-commands.ts +25 -0
- package/src/runtime/lifecycle/dispatch.ts +109 -0
- package/src/runtime/lifecycle/failure-lesson.ts +51 -0
- package/src/runtime/lifecycle/index.ts +34 -0
- package/src/runtime/lifecycle/inject-rules.ts +50 -0
- package/src/runtime/lifecycle/instructions-loaded.ts +22 -0
- package/src/runtime/lifecycle/kimi-rules-native.ts +44 -0
- package/src/runtime/lifecycle/lessons/dispatch.ts +88 -0
- package/src/runtime/lifecycle/lessons/reminder.ts +89 -0
- package/src/runtime/lifecycle/lessons/state.ts +23 -0
- package/src/runtime/lifecycle/memory/agent-lesson.ts +35 -0
- package/src/runtime/lifecycle/memory/capture-error.ts +41 -0
- package/src/runtime/lifecycle/memory/client.ts +65 -0
- package/src/runtime/lifecycle/memory/dispatch.ts +42 -0
- package/src/runtime/lifecycle/memory/recall.ts +30 -0
- package/src/runtime/lifecycle/memory/salience.ts +31 -0
- package/src/runtime/lifecycle/memory/state.ts +49 -0
- package/src/runtime/lifecycle/memory/track-ops.ts +15 -0
- package/src/runtime/lifecycle/post-compact.ts +38 -0
- package/src/runtime/lifecycle/post-edit-ts.ts +49 -0
- package/src/runtime/lifecycle/post-tracking.ts +40 -0
- package/src/runtime/lifecycle/pre-compact.ts +38 -0
- package/src/runtime/lifecycle/rules-root.ts +83 -0
- package/src/runtime/lifecycle/security/check-skill.ts +63 -0
- package/src/runtime/lifecycle/security/scan-patterns.ts +53 -0
- package/src/runtime/lifecycle/security/scan.ts +76 -0
- package/src/runtime/lifecycle/security/skill-state.ts +47 -0
- package/src/runtime/lifecycle/security/track-mcp.ts +29 -0
- package/src/runtime/lifecycle/security/track-skill-read.ts +31 -0
- package/src/runtime/lifecycle/seo/post-tool-use.ts +45 -0
- package/src/runtime/lifecycle/session-end.ts +17 -0
- package/src/runtime/lifecycle/session-start.ts +57 -0
- package/src/runtime/lifecycle/snapshot/board.ts +27 -0
- package/src/runtime/lifecycle/snapshot/format.ts +60 -0
- package/src/runtime/lifecycle/snapshot/git.ts +71 -0
- package/src/runtime/lifecycle/snapshot/index.ts +52 -0
- package/src/runtime/lifecycle/snapshot/version.ts +34 -0
- package/src/runtime/lifecycle/solid-detect.ts +56 -0
- package/src/runtime/lifecycle/stop-core.ts +35 -0
- package/src/runtime/lifecycle/subagent-cache.ts +61 -0
- package/src/runtime/lifecycle/task-completed.ts +94 -0
- package/src/runtime/lifecycle/teammate-idle-check.ts +63 -0
- package/src/runtime/lifecycle/teammate-idle.ts +28 -0
- package/src/runtime/lifecycle/tool-failure.ts +24 -0
- package/src/runtime/lifecycle/track-changes.ts +61 -0
- package/src/runtime/lifecycle/validate-solid.ts +70 -0
- package/src/runtime/lifecycle/validate-tailwind.ts +80 -0
- package/src/runtime/lifecycle-bridge.ts +74 -0
- package/src/runtime/mcp-key.ts +44 -0
- package/src/runtime/mcp-tool-name.ts +92 -0
- package/src/runtime/mcp.ts +91 -0
- package/src/runtime/modular.ts +77 -0
- package/src/runtime/normalize.ts +107 -0
- package/src/runtime/notices.ts +91 -0
- package/src/runtime/notification-sound.ts +49 -0
- package/src/runtime/notifications.ts +77 -0
- package/src/runtime/paths.ts +59 -0
- package/src/runtime/post-fanout.ts +41 -0
- package/src/runtime/post-outcome.ts +61 -0
- package/src/runtime/pre-allow.ts +97 -0
- package/src/runtime/precommit.ts +60 -0
- package/src/runtime/prompt-text.ts +38 -0
- package/src/runtime/receipt-capture.ts +34 -0
- package/src/runtime/record.ts +26 -0
- package/src/runtime/respond.ts +92 -0
- package/src/runtime/solid-file-size-gate.ts +58 -0
- package/src/runtime/solid-pre.ts +53 -0
- package/src/runtime/storage.ts +9 -0
- package/src/statusline/colors.ts +31 -0
- package/src/statusline/constants.ts +17 -0
- package/src/statusline/formatters.ts +54 -0
- package/src/statusline/index.ts +4 -0
- package/src/statusline/progress-bar.ts +46 -0
- package/src/tracking/codex-post-failure.ts +73 -0
- package/src/tracking/index.ts +2 -0
- package/src/tracking/integrity.ts +99 -0
- package/src/tracking/one-shot-dedup.ts +34 -0
- package/src/tracking/one-shot-failure.ts +42 -0
- package/src/tracking/one-shot-store.ts +95 -0
- package/src/tracking/one-shot.ts +93 -0
- package/src/tracking/receipts.ts +95 -0
- package/src/tracking/session-state.ts +94 -0
- package/src/tracking/store.ts +96 -0
- package/src/tracking/track-compact.ts +100 -0
- package/src/tracking/track-diff.ts +50 -0
- package/src/tracking/track-journal.ts +97 -0
- package/src/tracking/track-lock-sync.ts +83 -0
- package/src/tracking/track-lock.ts +67 -0
- package/src/util/compact-json.ts +23 -0
- package/src/util/index.ts +2 -0
- package/src/util/json-io.ts +47 -0
- package/src/util/project-root.ts +41 -0
- package/src/util/runtime-io.ts +85 -0
- package/src/util/semver.ts +30 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { extractText } from "../cache/mcp-response";
|
|
2
|
+
import { activityFor } from "./activity";
|
|
3
|
+
import { mcpPostStore } from "./mcp";
|
|
4
|
+
import { recordActivity } from "./record";
|
|
5
|
+
import { designGate } from "./design";
|
|
6
|
+
import { postEditContext } from "./lifecycle-bridge";
|
|
7
|
+
import { postTrackingSideEffects } from "./lifecycle/post-tracking";
|
|
8
|
+
import { aipilotPostToolUse, checkFileSize, validateTailwind } from "./lifecycle";
|
|
9
|
+
import { seoPostToolUseResponse } from "./lifecycle/seo/post-tool-use";
|
|
10
|
+
import { classifyAgentEvidence, recordAgentEvidence } from "../freshness/agent-evidence-record";
|
|
11
|
+
import { recordCodexSpawnEvidence } from "../freshness/codex-spawn-evidence";
|
|
12
|
+
import { captureBashReceipt } from "./receipt-capture";
|
|
13
|
+
import { recordCodexPostFailure } from "../tracking/codex-post-failure";
|
|
14
|
+
import { defaultStateDir } from "./paths";
|
|
15
|
+
import { fanOutFiles, firstFileMatch } from "./post-fanout";
|
|
16
|
+
import { postOutcome } from "./post-outcome";
|
|
17
|
+
import type { PreContext } from "./handle-pre";
|
|
18
|
+
import type { HandleOutcome } from "./handle";
|
|
19
|
+
|
|
20
|
+
function isCursorAfterFileEdit(id: string, payload: Record<string, unknown>): boolean {
|
|
21
|
+
if (id !== "cursor") return false;
|
|
22
|
+
const hookEvent = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
23
|
+
if (hookEvent) return /^afterFileEdit$/i.test(hookEvent);
|
|
24
|
+
return typeof payload.file_path === "string" && Array.isArray(payload.edits);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run the PostToolUse pipeline: store the MCP response, emit a design warning,
|
|
29
|
+
* record the activity into the session track, apply per-scope side-effects (SEO
|
|
30
|
+
* deny, aipilot task cache), then inject the post-edit context.
|
|
31
|
+
*
|
|
32
|
+
* POST is advisory-only for the design pipeline: it can never undo a tool
|
|
33
|
+
* that already ran (the hard block lives in the PreToolUse `designFilesGate`).
|
|
34
|
+
* `designGate` therefore stays on the RAW, un-fanned event — its
|
|
35
|
+
* `recordPost` apply_patch branch is promote-only and resolves relative
|
|
36
|
+
* `design-system.md` paths via `join(cwd, …)` (design-helpers.ts); fanning
|
|
37
|
+
* that call would instead route the file through the Write/Edit branch,
|
|
38
|
+
* which reads `event.filePath` UNRESOLVED (breaking cwd-relative promotion)
|
|
39
|
+
* and can DEGRADE the state — both forbidden by the apply_patch promote-only
|
|
40
|
+
* doctrine (see `design-files-gate.ts` module doc). Only `designPassNotice`
|
|
41
|
+
* (pure formatting, no disk access, no state write) is fanned via
|
|
42
|
+
* {@link fanOutFiles}, so `apply_patch` gets one notice line per real file
|
|
43
|
+
* instead of none.
|
|
44
|
+
* @param ctx - The resolved context (same shape as the pre pipeline).
|
|
45
|
+
* @returns The native hook outcome.
|
|
46
|
+
*/
|
|
47
|
+
export async function handlePost(ctx: PreContext): Promise<HandleOutcome> {
|
|
48
|
+
const { id, payload, event, framework, mcpDir, file, opts } = ctx;
|
|
49
|
+
const cursorAfterFileEdit = isCursorAfterFileEdit(id, payload);
|
|
50
|
+
const designCacheDir = ctx.designCacheDir ?? mcpDir;
|
|
51
|
+
let response = payload.tool_response ?? payload.tool_output;
|
|
52
|
+
if (id === "cursor" && /^afterMCPExecution$/i.test(event.eventName ?? "")) {
|
|
53
|
+
try {
|
|
54
|
+
response = typeof payload.result_json === "string" ? JSON.parse(payload.result_json) : payload.result_json;
|
|
55
|
+
} catch {
|
|
56
|
+
response = undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
mcpPostStore(event.tool, event.input, response, mcpDir);
|
|
60
|
+
const designWarn = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
|
|
61
|
+
const activities = activityFor({ tool: event.tool, input: event.input, sessionId: event.sessionId, framework, now: opts.now, responseLength: extractText(response).length });
|
|
62
|
+
for (const activity of activities) await recordActivity(file, activity);
|
|
63
|
+
// Session-scoped evidence (parity track-subagent-research.py): sub-agent hooks
|
|
64
|
+
// carry the LEAD's session_id — Task/Agent launches excluded (credited above).
|
|
65
|
+
const evidence = classifyAgentEvidence(event.tool, event.input, response);
|
|
66
|
+
if (evidence) await recordAgentEvidence(file, evidence, opts.now, typeof payload.agent_id === "string" ? payload.agent_id : undefined);
|
|
67
|
+
// Codex multi_agent_v2 `spawn_agent` -> same session track (no-op for every
|
|
68
|
+
// other harness / non-spawn tool / missing `agent_type`; see module doc).
|
|
69
|
+
await recordCodexSpawnEvidence(file, id, event.tool, event.input, opts.now);
|
|
70
|
+
// Verification receipts (tsc/bun test runs) — structured responses only
|
|
71
|
+
// (Kimi's string `tool_output` would forge a success receipt; see module).
|
|
72
|
+
await captureBashReceipt(file, event.tool, event.command, payload.tool_result, response, opts.now);
|
|
73
|
+
if (id === "codex") recordCodexPostFailure(event.tool, payload.tool_result ?? response, { now: opts.now, dir: defaultStateDir(opts.cwd), sessionId: event.sessionId });
|
|
74
|
+
// Codex `apply_patch` and Cursor `afterFileEdit` fan into per-file events for
|
|
75
|
+
// tracking, validation, post-edit context, and notices.
|
|
76
|
+
const files = fanOutFiles(event);
|
|
77
|
+
for (const f of files) postTrackingSideEffects(opts.scope ?? "core", f, f.input, opts.now, payload, opts.cwd);
|
|
78
|
+
const seoDeny = opts.scope === "seo" ? seoPostToolUseResponse(payload) : null;
|
|
79
|
+
if (seoDeny && !cursorAfterFileEdit) return { stdout: seoDeny, exit: 0 };
|
|
80
|
+
if (opts.scope === "solid") {
|
|
81
|
+
const solidWarn = firstFileMatch(files, checkFileSize);
|
|
82
|
+
if (solidWarn && !cursorAfterFileEdit) return { stdout: solidWarn, exit: 0 };
|
|
83
|
+
}
|
|
84
|
+
if (opts.scope === "tailwindcss") {
|
|
85
|
+
const tailwindWarn = firstFileMatch(files, validateTailwind);
|
|
86
|
+
if (tailwindWarn && !cursorAfterFileEdit) return { stdout: tailwindWarn, exit: 0 };
|
|
87
|
+
}
|
|
88
|
+
if (opts.scope === "aipilot" && (event.tool === "TaskCreate" || event.tool === "TaskUpdate" || event.tool === "Write" || event.tool === "Edit")) {
|
|
89
|
+
const out = await aipilotPostToolUse(payload, opts.cwd, id);
|
|
90
|
+
if (out && !cursorAfterFileEdit) return { stdout: out, exit: 0 };
|
|
91
|
+
}
|
|
92
|
+
let extra = "";
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
extra = await postEditContext(opts.scope ?? "core", f, opts.now, id);
|
|
95
|
+
if (extra) break;
|
|
96
|
+
}
|
|
97
|
+
return postOutcome({
|
|
98
|
+
id, agentId: typeof payload.agent_id === "string" ? payload.agent_id : "", sessionId: event.sessionId,
|
|
99
|
+
now: opts.now, cwd: opts.cwd, activities, files,
|
|
100
|
+
designCacheDir, designWarn, extra,
|
|
101
|
+
cursorAfterFileEdit,
|
|
102
|
+
cursorEventName: typeof payload.hook_event_name === "string" ? payload.hook_event_name : "",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { loadRefs } from "../refs/loader";
|
|
3
|
+
import { gate, gateCommandCandidates } from "./gate";
|
|
4
|
+
import { MCP_TTL_MS, mcpPreIntercept } from "./mcp";
|
|
5
|
+
import type { NormalizedEvent } from "./normalize";
|
|
6
|
+
import { recordActivity } from "./record";
|
|
7
|
+
import { respond } from "./respond";
|
|
8
|
+
import { withDenyNotice } from "./deny-notice";
|
|
9
|
+
import { designGate } from "./design";
|
|
10
|
+
import { taskContext } from "./inject-context";
|
|
11
|
+
import { securityAdvisory, securityAdvisoryForPatch } from "./lifecycle/security/check-skill";
|
|
12
|
+
import { solidScopeOutcome } from "./solid-pre";
|
|
13
|
+
import { isAgentTool } from "./is-agent-tool";
|
|
14
|
+
import { allowOutcome } from "./pre-allow";
|
|
15
|
+
import { applyPatchGate } from "./apply-patch-gate";
|
|
16
|
+
import { isBypassPermissions } from "../adapters/codex/permission-mode";
|
|
17
|
+
import { evaluate } from "../policy/evaluate";
|
|
18
|
+
import { confirmGate } from "./confirm/confirm-gate";
|
|
19
|
+
import type { HandleOptions, HandleOutcome } from "./handle";
|
|
20
|
+
|
|
21
|
+
/** Context the PreToolUse pipeline needs (resolved once by {@link handleHook}). */
|
|
22
|
+
export interface PreContext {
|
|
23
|
+
id: string;
|
|
24
|
+
payload: Record<string, unknown>;
|
|
25
|
+
event: NormalizedEvent;
|
|
26
|
+
framework: string;
|
|
27
|
+
mcpDir: string;
|
|
28
|
+
/**
|
|
29
|
+
* Session-anchored design-pipeline cache dir (see design-cache-resolve.ts) —
|
|
30
|
+
* distinct from `mcpDir`. Optional: pre-existing context literals (tests
|
|
31
|
+
* built before this field existed) omit it and fall back to `mcpDir`
|
|
32
|
+
* unchanged, so they keep their old, already-passing behavior verbatim.
|
|
33
|
+
*/
|
|
34
|
+
designCacheDir?: string;
|
|
35
|
+
file: string;
|
|
36
|
+
opts: HandleOptions;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the PreToolUse pipeline: MCP/WebFetch cache intercept, design gate, APEX Task context injection, then the stateless+APEX gate chain, returning the native hook outcome (deny/ask/inject or allow).
|
|
41
|
+
* @param ctx - The resolved pre-context.
|
|
42
|
+
* @returns The hook outcome.
|
|
43
|
+
*/
|
|
44
|
+
export async function handlePre(ctx: PreContext): Promise<HandleOutcome> {
|
|
45
|
+
const { id, payload, event, framework, mcpDir, file, opts } = ctx;
|
|
46
|
+
const designCacheDir = ctx.designCacheDir ?? mcpDir;
|
|
47
|
+
const intercept = mcpPreIntercept(id, event.tool, event.input, mcpDir, MCP_TTL_MS, opts.now);
|
|
48
|
+
if (intercept !== null) {
|
|
49
|
+
if (intercept.docSource) await recordActivity(file, { kind: "doc", framework, sessionId: event.sessionId, source: intercept.docSource });
|
|
50
|
+
return { stdout: intercept.stdout, exit: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const designBlock = designGate(payload, event, designCacheDir, opts.cwd, opts.corpusRoot);
|
|
54
|
+
if (designBlock) return { stdout: withDenyNotice(id, respond(id, designBlock, event.eventName ?? "PreToolUse"), designBlock, event.sessionId, dirname(file), opts.now), exit: 0 };
|
|
55
|
+
|
|
56
|
+
if (id === "cursor" && (event.eventName === "beforeReadFile" || event.eventName === "beforeTabFileRead")) {
|
|
57
|
+
const readPolicy = evaluate({ tool: event.tool, filePath: event.filePath });
|
|
58
|
+
return {
|
|
59
|
+
stdout: readPolicy.prompt
|
|
60
|
+
? respond(id, readPolicy.prompt, event.eventName)
|
|
61
|
+
: JSON.stringify({ permission: "allow" }),
|
|
62
|
+
exit: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Security scope is advisory-only (ports check-security-skill.py): emit the
|
|
67
|
+
// non-blocking advisory when the skill is unread, else allow — NEVER run the
|
|
68
|
+
// core APEX/SOLID/file-size gate chain (the security plugin never did).
|
|
69
|
+
if (opts.scope === "security") {
|
|
70
|
+
return { stdout: event.files?.length ? securityAdvisoryForPatch(event.files, opts.now) : securityAdvisory(event.tool, event.filePath ?? "", opts.now), exit: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Solid scope mirrors security above: run ONLY the solid-scope gates
|
|
74
|
+
// (file-size deny, then Go/Python interface-location) and ALWAYS return —
|
|
75
|
+
// NEVER the core gate chain: core-guards owns it, so falling through would
|
|
76
|
+
// run gate() twice per edit (duplicate denies + added latency).
|
|
77
|
+
if (opts.scope === "solid") {
|
|
78
|
+
return solidScopeOutcome(id, event, file, opts.now);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// PreToolUse sub-agent dispatch (Task on claude/codex, Agent/AgentSwarm on
|
|
82
|
+
// kimi): inject APEX sub-agent context when the target apex dir exists.
|
|
83
|
+
if (isAgentTool(event.tool)) {
|
|
84
|
+
const taskCtx = taskContext(opts.cwd, id);
|
|
85
|
+
if (taskCtx) return { stdout: taskCtx, exit: 0 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Codex `apply_patch`: normalize.ts fanned the freeform patch into per-file
|
|
89
|
+
// changes. OR their static verdicts — one violating hunk blocks the whole
|
|
90
|
+
// envelope. `event.files` is undefined for every other tool/harness.
|
|
91
|
+
if (event.files && event.files.length > 0) {
|
|
92
|
+
const patchPrompt = applyPatchGate(event.files, opts.cwd);
|
|
93
|
+
if (patchPrompt) return { stdout: withDenyNotice(id, respond(id, patchPrompt, event.eventName ?? "PreToolUse"), patchPrompt, event.sessionId, dirname(file), opts.now), exit: 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const refs = opts.refsDir ? await loadRefs(opts.refsDir) : undefined;
|
|
97
|
+
const gateInput = {
|
|
98
|
+
sessionId: event.sessionId,
|
|
99
|
+
framework,
|
|
100
|
+
tool: event.tool,
|
|
101
|
+
filePath: event.filePath,
|
|
102
|
+
content: event.content,
|
|
103
|
+
command: event.command,
|
|
104
|
+
cwd: opts.cwd,
|
|
105
|
+
refs,
|
|
106
|
+
isReplaceAll: event.input.replace_all === true,
|
|
107
|
+
oldString: event.oldString,
|
|
108
|
+
agentType: event.agentType,
|
|
109
|
+
agentId: typeof payload.agent_id === "string" ? payload.agent_id : undefined,
|
|
110
|
+
windowMs: opts.windowMs,
|
|
111
|
+
now: opts.now,
|
|
112
|
+
trackFile: file,
|
|
113
|
+
transcriptPath: typeof payload.transcript_path === "string" ? payload.transcript_path : undefined,
|
|
114
|
+
neverApproval: id === "codex" && isBypassPermissions(event.permissionMode),
|
|
115
|
+
};
|
|
116
|
+
const prompt = id === "cursor" && (event.commandCandidates?.length ?? 0) > 1
|
|
117
|
+
? await gateCommandCandidates(gateInput, event.commandCandidates!)
|
|
118
|
+
: await gate(gateInput);
|
|
119
|
+
if (prompt) {
|
|
120
|
+
// CONFIRM <code> flow: ONLY changes anything when Codex/Kimi are about to
|
|
121
|
+
// downgrade THIS `ask` to a hard deny (confirmGate returns null in every
|
|
122
|
+
// other case — including every claude-code call, unconditionally, and
|
|
123
|
+
// every non-`ask` prompt kind — so the line below is byte-identical to
|
|
124
|
+
// the pre-CONFIRM behavior whenever it applies).
|
|
125
|
+
const confirm = confirmGate(id, prompt, event.command, event.sessionId, opts.now, opts.home,
|
|
126
|
+
id === "codex" ? { tool: event.tool, cwd: event.cwd ?? opts.cwd, toolUseId: event.toolUseId } : undefined);
|
|
127
|
+
if (confirm?.allow) {
|
|
128
|
+
return allowOutcome(id, event, payload, designCacheDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot);
|
|
129
|
+
}
|
|
130
|
+
const finalPrompt = confirm ? confirm.prompt : prompt;
|
|
131
|
+
return { stdout: withDenyNotice(id, respond(id, finalPrompt, event.eventName ?? "PreToolUse"), finalPrompt, event.sessionId, dirname(file), opts.now), exit: 0 };
|
|
132
|
+
}
|
|
133
|
+
// Every gate allowed: hand off to the ALLOW-path assembly (pass notice +
|
|
134
|
+
// decision-time lesson + evidence-fresh notice). A deny/ask already returned
|
|
135
|
+
// above, so nothing it emits can block nor override a decision.
|
|
136
|
+
return allowOutcome(id, event, payload, designCacheDir, opts.cwd, { trackFile: file, windowMs: opts.windowMs, now: opts.now }, opts.corpusRoot);
|
|
137
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-pipeline async scope interception for {@link handleHook}. The aipilot and
|
|
3
|
+
* memory scopes reach external resources (cache files / Graphiti HTTP) and may
|
|
4
|
+
* emit stdout for lifecycle events, so they run before the sync gate pipeline.
|
|
5
|
+
*/
|
|
6
|
+
import { dispatchAipilot } from "./lifecycle";
|
|
7
|
+
import { dispatchMemory } from "./lifecycle/memory/dispatch";
|
|
8
|
+
import { cursorEventContract } from "../adapters/cursor/events";
|
|
9
|
+
import { toCursorLifecycleResponse } from "../adapters/cursor/respond";
|
|
10
|
+
import type { PluginScope } from "./lifecycle";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run the async per-scope dispatcher for the invoking scope, if any.
|
|
14
|
+
* @param scope - The invoking plugin scope.
|
|
15
|
+
* @param event - The raw hook event name.
|
|
16
|
+
* @param payload - The raw hook payload.
|
|
17
|
+
* @param cwd - Project root.
|
|
18
|
+
* @param now - Clock.
|
|
19
|
+
* @param id - Harness target id (defaults to "claude-code" — zero-regression default).
|
|
20
|
+
* @returns The native stdout when intercepted, or `null` to fall through.
|
|
21
|
+
*/
|
|
22
|
+
export async function asyncScopeStdout(scope: PluginScope | undefined, event: string, payload: Record<string, unknown>, cwd: string, now: number, id: string = "claude-code"): Promise<string | null> {
|
|
23
|
+
const dispatchedEvent = id === "cursor" ? cursorEventContract(event).lifecycle : event;
|
|
24
|
+
if (dispatchedEvent === null) return null;
|
|
25
|
+
let stdout: string | null = null;
|
|
26
|
+
if (scope === "aipilot") stdout = await dispatchAipilot(dispatchedEvent, payload, cwd, now, undefined, id);
|
|
27
|
+
if (scope === "memory") stdout = await dispatchMemory(dispatchedEvent, payload, cwd, now);
|
|
28
|
+
return id === "cursor" && stdout !== null ? toCursorLifecycleResponse(stdout, event) : stdout;
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PluginScope } from "./lifecycle";
|
|
2
|
+
|
|
3
|
+
/** Options for {@link handleHook} (caller supplies the clock + project root). */
|
|
4
|
+
export interface HandleOptions {
|
|
5
|
+
now: number;
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** Directory of SOLID reference `.md` files for `solidReadGate` (else inert). */
|
|
8
|
+
refsDir?: string;
|
|
9
|
+
/** APEX freshness window in ms (from `FUSE_ENFORCE_TTL_SEC`). */
|
|
10
|
+
windowMs?: number;
|
|
11
|
+
/** Which plugin's hooks.json invoked the harness (selects lifecycle behavior). */
|
|
12
|
+
scope?: PluginScope;
|
|
13
|
+
/**
|
|
14
|
+
* Test/integration seam: the resolved refs-design corpus root ("" = absent).
|
|
15
|
+
* Undefined = resolve from disk (the production behavior); supplied = used
|
|
16
|
+
* as-is, so tests can drive the corpus-present branch deterministically.
|
|
17
|
+
*/
|
|
18
|
+
corpusRoot?: string;
|
|
19
|
+
/** Test-only OS home override for the session-anchored design cache dir (see design-cache-resolve.ts). Undefined = real `os.homedir()`. */
|
|
20
|
+
home?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the hook bin should print + exit with. */
|
|
24
|
+
export interface HandleOutcome {
|
|
25
|
+
stdout: string;
|
|
26
|
+
exit: number;
|
|
27
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { projectLayout } from "../config/layout";
|
|
2
|
+
import { detectFramework } from "../policy/detect-framework";
|
|
3
|
+
import { detectCreationIntent } from "../policy/creation-intent";
|
|
4
|
+
import { recordBrainstormRequired } from "../tracking/session-state";
|
|
5
|
+
import { withTrack } from "../tracking/store";
|
|
6
|
+
import { normalizeEvent } from "./normalize";
|
|
7
|
+
import { defaultStateDir, trackFile } from "./paths";
|
|
8
|
+
import { designLifecycle } from "./design-lifecycle";
|
|
9
|
+
import { promptSubmitContext } from "./inject-context";
|
|
10
|
+
import { lifecycleStdout } from "./lifecycle-bridge";
|
|
11
|
+
import { handlePre } from "./handle-pre";
|
|
12
|
+
import { handlePost } from "./handle-post";
|
|
13
|
+
import { asyncScopeStdout } from "./handle-scope-async";
|
|
14
|
+
import { resolveDesignCacheDir } from "./design-cache-resolve";
|
|
15
|
+
import { resyncCodexAgents } from "./lifecycle/codex-resync/resync";
|
|
16
|
+
import { resetFragmentRegistry } from "./fragment-registry";
|
|
17
|
+
import { attachBudgetRecap } from "./inject-budget-recap";
|
|
18
|
+
import { promptText } from "./prompt-text";
|
|
19
|
+
import { handleConfirmSubmit } from "./confirm/confirm-submit";
|
|
20
|
+
import { submitCodexConfirmation } from "./confirm/codex-confirm";
|
|
21
|
+
import { codexPromptOrigin } from "./confirm/codex-prompt-origin";
|
|
22
|
+
import { cursorProjectCwd } from "../adapters/cursor/context";
|
|
23
|
+
import { toCursorLifecycleResponse } from "../adapters/cursor/respond";
|
|
24
|
+
import type { HandleOptions, HandleOutcome } from "./handle-types";
|
|
25
|
+
export type { HandleOptions, HandleOutcome } from "./handle-types";
|
|
26
|
+
|
|
27
|
+
/** Raw Claude hook event name from a payload (empty when absent). */
|
|
28
|
+
function rawEventName(payload: Record<string, unknown>): string {
|
|
29
|
+
return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The full hook handler: on a PRE event it gates the tool-use (stateless guards
|
|
34
|
+
* then APEX gates from the session track) and returns the native response; on a
|
|
35
|
+
* POST event it records the activity into the track. The loop that makes the
|
|
36
|
+
* package behave like the Claude plugin, on any harness.
|
|
37
|
+
*/
|
|
38
|
+
async function handleHookCore(id: string, payload: Record<string, unknown>, opts: HandleOptions): Promise<HandleOutcome> {
|
|
39
|
+
const event = normalizeEvent(id, payload);
|
|
40
|
+
if (id === "cursor") {
|
|
41
|
+
const cursorCwd = cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, opts.cwd);
|
|
42
|
+
if (cursorCwd !== opts.cwd) opts = { ...opts, cwd: cursorCwd };
|
|
43
|
+
}
|
|
44
|
+
const rawPrompt = payload.prompt;
|
|
45
|
+
const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined;
|
|
46
|
+
if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== undefined) {
|
|
47
|
+
submitCodexConfirmation(event.sessionId, userPrompt, opts.now, opts.home, codexPromptOrigin(payload));
|
|
48
|
+
}
|
|
49
|
+
// Fresh slate for this invocation's capFragment tally — one hook event is
|
|
50
|
+
// exactly one lifecycle branch below (see dispatchLifecycle), so a single
|
|
51
|
+
// reset here can never mix fragments across unrelated events.
|
|
52
|
+
resetFragmentRegistry();
|
|
53
|
+
const layout = projectLayout(opts.cwd);
|
|
54
|
+
const file = trackFile(event.sessionId, defaultStateDir(opts.cwd));
|
|
55
|
+
const mcpDir = layout.cacheDir;
|
|
56
|
+
// Design pipeline (flag + `.design-state-*.json`) is anchored on the session
|
|
57
|
+
// id, not `mcpDir`/cwd — see design-cache-resolve.ts. `mcpDir` itself stays
|
|
58
|
+
// cwd-derived for the unrelated MCP/WebFetch cache.
|
|
59
|
+
const designCacheDir = resolveDesignCacheDir(event.sessionId, mcpDir, opts.home);
|
|
60
|
+
const framework = detectFramework(event.filePath ?? "", event.content ?? "", opts.cwd);
|
|
61
|
+
|
|
62
|
+
// Design-agent lifecycle (SubagentStart/Stop): init/cleanup the pipeline state machine.
|
|
63
|
+
// claude-code + codex only: verified against openai/codex's OWN generated hook schemas
|
|
64
|
+
// (codex-rs/hooks/schema/generated/subagent-{start,stop}.command.input.schema.json @ 385c0a93,
|
|
65
|
+
// superseding the stale 44918ea1 Cargo.toml-only bump) — `agent_id`/`agent_type` are REQUIRED
|
|
66
|
+
// on both events, unconditional on `hide_spawn_agent_metadata` (that flag only reshapes the
|
|
67
|
+
// spawn_agent tool RESULT, not the hook payload). `payload.prompt` (design-mode "component"
|
|
68
|
+
// detection below) is absent from Codex's schema — degrades to detectMode's default, not a
|
|
69
|
+
// break. Cursor/Gemini/Cline/Hermes remain unverified — NOT added without the same proof.
|
|
70
|
+
if ((id === "claude-code" || id === "codex") && designLifecycle(payload, designCacheDir, opts.cwd, String(opts.now), opts.now)) {
|
|
71
|
+
return { stdout: "", exit: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Codex-only, fail-open: refresh the plugin agents/commands cache on SessionStart when its fingerprint changed (strict no-op on every other harness).
|
|
75
|
+
if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
|
|
76
|
+
|
|
77
|
+
// Async per-scope lifecycle (aipilot cache handlers + memory-neural Graphiti).
|
|
78
|
+
const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), payload, opts.cwd, opts.now, id);
|
|
79
|
+
if (asyncOut !== null) return { stdout: asyncOut, exit: 0 };
|
|
80
|
+
|
|
81
|
+
// Ported lifecycle/session/context hooks (SessionStart, SubagentStart/Stop, etc.).
|
|
82
|
+
const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now, id);
|
|
83
|
+
if (life !== null) {
|
|
84
|
+
// Claude-Code-only: attachBudgetRecap's systemMessage envelope assumes the
|
|
85
|
+
// Claude adapter's stdout shape (mirrors the designLifecycle gate above).
|
|
86
|
+
const stdout = id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life;
|
|
87
|
+
return { stdout, exit: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// UserPromptSubmit (core scope): brainstorm flag + CLAUDE.md injection.
|
|
91
|
+
// `payload.prompt` is a string on Claude Code/Codex, an array of content
|
|
92
|
+
// blocks on Kimi (see promptText) — either shape is normalized to text;
|
|
93
|
+
// anything else (field absent, or an unrecognized type) stays `undefined`
|
|
94
|
+
// so the block below is skipped exactly as before promptText existed.
|
|
95
|
+
if (userPrompt !== undefined) {
|
|
96
|
+
if (id !== "codex") handleConfirmSubmit(event.sessionId, userPrompt, opts.now, opts.home);
|
|
97
|
+
await withTrack(file, (track) => recordBrainstormRequired(track, detectCreationIntent(userPrompt)));
|
|
98
|
+
return { stdout: promptSubmitContext(userPrompt, opts.cwd, id), exit: 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (event.phase === "post") {
|
|
102
|
+
return handlePost({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run one hook and adapt every Cursor scope outcome at the common runtime exit.
|
|
110
|
+
* Other harnesses retain the core handler's stdout and exit status unchanged.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleHook(id: string, payload: Record<string, unknown>, opts: HandleOptions): Promise<HandleOutcome> {
|
|
113
|
+
const outcome = await handleHookCore(id, payload, opts);
|
|
114
|
+
if (id !== "cursor") return outcome;
|
|
115
|
+
return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, rawEventName(payload)) };
|
|
116
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { atomicWrite } from "../util/json-io";
|
|
5
|
+
|
|
6
|
+
/** Home `~/.claude` dir — per-harness config (CLAUDE.md, logs, plugins). */
|
|
7
|
+
export function claudeHome(home: string = homedir()): string {
|
|
8
|
+
return join(home, ".claude");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Neutral, harness-agnostic home for fuse-harness's OWN cache/state: `~/.fuse-harness`. */
|
|
12
|
+
export function fuseHarnessHome(home: string = homedir()): string {
|
|
13
|
+
return join(home, ".fuse-harness");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `~/.fuse-harness/cache` base dir for session/cache state (shared across harnesses). */
|
|
17
|
+
export function fusengineCache(home: string = homedir()): string {
|
|
18
|
+
return join(fuseHarnessHome(home), "cache");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `~/.fuse-harness/cache/sessions` — per-session JSON state dir. */
|
|
22
|
+
export function sessionsDir(home: string = homedir()): string {
|
|
23
|
+
return join(fusengineCache(home), "sessions");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
27
|
+
|
|
28
|
+
/** Validate a session id (1-128 url-safe chars); null when invalid. */
|
|
29
|
+
export function sanitizeSessionId(sid: unknown): string | null {
|
|
30
|
+
const s = String(sid ?? "").trim();
|
|
31
|
+
return SID_RE.test(s) ? s : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Unified per-session state file path: `sessions/session-<sid>.json`. */
|
|
35
|
+
export function sessionStatePath(sid: string, home: string = homedir()): string {
|
|
36
|
+
return join(sessionsDir(home), `session-${sid}.json`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load a session-state dict, or `{}` when missing/corrupt (mirrors Python). */
|
|
40
|
+
export function loadSessionState(sid: string, home: string = homedir()): Record<string, unknown> {
|
|
41
|
+
const path = sessionStatePath(sid, home);
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(path)) return {};
|
|
44
|
+
const data: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
45
|
+
return typeof data === "object" && data !== null && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Atomically persist a session-state dict (0o600 via atomicWrite, indent 2). */
|
|
52
|
+
export function saveSessionState(sid: string, state: Record<string, unknown>, home: string = homedir()): void {
|
|
53
|
+
mkdirSync(sessionsDir(home), { recursive: true, mode: 0o700 });
|
|
54
|
+
atomicWrite(sessionStatePath(sid, home), JSON.stringify(state, null, 2));
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from "./paths";
|
|
2
|
+
export * from "./storage";
|
|
3
|
+
export * from "./record";
|
|
4
|
+
export * from "./activity";
|
|
5
|
+
export * from "./gate";
|
|
6
|
+
export * from "./dry";
|
|
7
|
+
export * from "./precommit";
|
|
8
|
+
export * from "./normalize";
|
|
9
|
+
export * from "./respond";
|
|
10
|
+
export * from "./mcp";
|
|
11
|
+
export * from "./inject-context";
|
|
12
|
+
export * from "./home-state";
|
|
13
|
+
export * from "./fs-cleanup";
|
|
14
|
+
export * from "./dev-context";
|
|
15
|
+
export * from "./lifecycle";
|
|
16
|
+
export * from "./lifecycle-bridge";
|
|
17
|
+
export * from "./handle-pre";
|
|
18
|
+
export * from "./handle";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle context-injection renderer. Every emitter that injects text into
|
|
3
|
+
* the model's context (rules, lessons, CLAUDE.md preamble, ...) goes through
|
|
4
|
+
* here instead of calling the Claude builders directly:
|
|
5
|
+
* - kimi: raw text on stdout — Kimi Code CLI appends stdout to the context on
|
|
6
|
+
* exit 0 (UserPromptSubmit is blockable; the Claude-shaped
|
|
7
|
+
* `additionalContext` envelope would be ignored or leak as raw JSON text).
|
|
8
|
+
* - every other harness: the historical Claude-shaped envelope, byte-identical
|
|
9
|
+
* to what the call sites produced before this module existed (zero-regression
|
|
10
|
+
* default — `attachSystemMessage` concatenates notices with "\n", so a single
|
|
11
|
+
* combined notice equals the previous chained calls).
|
|
12
|
+
*/
|
|
13
|
+
import { attachSystemMessage, contextResponse } from "../adapters/claude";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a context injection for the firing harness.
|
|
17
|
+
* @param id - Harness id.
|
|
18
|
+
* @param event - The firing hook event name (envelope tag for Claude shapes).
|
|
19
|
+
* @param text - The context body to inject.
|
|
20
|
+
* @param notice - Optional human-facing notice (systemMessage on Claude
|
|
21
|
+
* shapes; appended as plain text for kimi).
|
|
22
|
+
* @returns The native hook stdout.
|
|
23
|
+
*/
|
|
24
|
+
export function renderInform(id: string, event: string, text: string, notice?: string): string {
|
|
25
|
+
if (id === "kimi") return notice ? `${text}\n\n${notice}` : text;
|
|
26
|
+
const base = contextResponse(event, text);
|
|
27
|
+
return notice ? attachSystemMessage(base, notice) : base;
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module inject-budget-recap
|
|
3
|
+
* ONE event-level recap of every {@link module:inject-budget.capFragment}
|
|
4
|
+
* -tracked fragment injected for a SessionStart/SubagentStart event — the
|
|
5
|
+
* aggregated view the per-fragment caps never gave: each injection point
|
|
6
|
+
* (dev-context, snapshot sections, lessons, apex-subagent, ...) caps and
|
|
7
|
+
* reports itself in ISOLATION, with no total across the whole event.
|
|
8
|
+
*
|
|
9
|
+
* Rides the user-visible `systemMessage` channel, Claude-Code-only (gate at
|
|
10
|
+
* the call site — mirrors the existing `id === "claude-code"` pattern for
|
|
11
|
+
* `designLifecycle` in handle.ts): other adapters do not all re-export
|
|
12
|
+
* `attachSystemMessage`, and stamping a Claude-shaped envelope onto another
|
|
13
|
+
* harness's stdout shape would be silently wrong there.
|
|
14
|
+
*
|
|
15
|
+
* Deduped via {@link module:inject-dedup.onceExclusive}, not the JSON
|
|
16
|
+
* `oncePerWindow`: SessionStart/SubagentStart fan out across every installed
|
|
17
|
+
* plugin exactly like PostToolUse does (see burst-window.ts) — a shared-JSON
|
|
18
|
+
* read-modify-write here would risk the same lost-update race already fixed
|
|
19
|
+
* for the sniper reminder (lesson 2026-07-05 16:00).
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
import { attachSystemMessage } from "../adapters/claude";
|
|
23
|
+
import { budgetReport } from "./inject-budget";
|
|
24
|
+
import { fragmentRegistry } from "./fragment-registry";
|
|
25
|
+
import { onceExclusive } from "./inject-dedup";
|
|
26
|
+
import { BURST_DEDUP_MS } from "./burst-window";
|
|
27
|
+
import { defaultStateDir } from "./paths";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Attach the aggregated recap onto `stdout` when 2+ fragments were recorded
|
|
31
|
+
* for `rawEvent`. A lone fragment already carries its own visibility (its
|
|
32
|
+
* producer's own per-fragment report, when it has one) — no recap is added
|
|
33
|
+
* for that case, so the common single-fragment event stays noise-free.
|
|
34
|
+
* @param stdout - The already-rendered hook stdout for this event.
|
|
35
|
+
* @param rawEvent - The raw hook event name (only SessionStart/SubagentStart qualify; others pass through unchanged).
|
|
36
|
+
* @param sessionId - Current session id (dedup scope).
|
|
37
|
+
* @param cwd - Project root (state-dir scope for the dedup marker).
|
|
38
|
+
* @param now - Clock.
|
|
39
|
+
* @returns `stdout` with a `systemMessage` recap attached, or `stdout` unchanged.
|
|
40
|
+
*/
|
|
41
|
+
export function attachBudgetRecap(stdout: string, rawEvent: string, sessionId: string, cwd: string, now: number): string {
|
|
42
|
+
if (rawEvent !== "SessionStart" && rawEvent !== "SubagentStart") return stdout;
|
|
43
|
+
const fragments = fragmentRegistry();
|
|
44
|
+
if (fragments.length <= 1) return stdout;
|
|
45
|
+
if (!onceExclusive(`budget:${sessionId}:${rawEvent}`, BURST_DEDUP_MS, { now, dir: defaultStateDir(cwd) })) return stdout;
|
|
46
|
+
return attachSystemMessage(stdout, budgetReport(fragments));
|
|
47
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module inject-budget
|
|
3
|
+
* Hard per-fragment ceiling for harness-PRODUCED context injections (lessons,
|
|
4
|
+
* snapshot sections, APEX task context, cartographer/dev-context blocks) —
|
|
5
|
+
* inspired by Codex's "ContextualUserFragment, no item > 10K tokens" pattern.
|
|
6
|
+
* NEVER apply this to owner-authored content (CLAUDE.md/rules): that path is
|
|
7
|
+
* a locked invariant (see the "owner invariant" tests in
|
|
8
|
+
* test/dedup-inject.test.ts) and must always ship uncapped and in full.
|
|
9
|
+
*
|
|
10
|
+
* A real regression motivated this: MEMORY/LESSON.md's SessionStart injection
|
|
11
|
+
* had grown to ~44k tokens with no per-fragment or total alarm, silently
|
|
12
|
+
* bloating every SessionStart/SubagentStart turn.
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
import { recordFragment } from "./fragment-registry";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hard cap per fragment, in characters. ~8000 chars ≈ 2000 tokens at the
|
|
19
|
+
* conservative ~4 chars/token ratio for mixed FR/EN prose — the accepted
|
|
20
|
+
* approximation absent a real tokenizer (cf. LangChain's `trim_messages`
|
|
21
|
+
* fallback token_counter). Chosen because a SINGLE SessionStart already
|
|
22
|
+
* stacks several harness fragments (lessons, snapshot, dev-context,
|
|
23
|
+
* cartographer) on top of the uncapped CLAUDE.md block, and the known 11x
|
|
24
|
+
* multi-plugin hook fan-out can multiply whatever ships — 2k tokens/fragment
|
|
25
|
+
* keeps the harness-produced share bounded even under that fan-out, while
|
|
26
|
+
* still leaving room for a few dozen useful lines of content.
|
|
27
|
+
*/
|
|
28
|
+
export const FRAGMENT_CHAR_CAP = 8000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cap `text` at {@link FRAGMENT_CHAR_CAP} characters, cutting at the last
|
|
32
|
+
* newline within budget so no line is chopped mid-sentence. Text at or under
|
|
33
|
+
* the cap is returned byte-identical (no-op). Over the cap, a single English
|
|
34
|
+
* truncation line is appended so the reader knows content was dropped and
|
|
35
|
+
* that the SOURCE FILE itself is untouched (only this injected view is cut).
|
|
36
|
+
* @param label - Short identifier of the fragment (e.g. "lessons", "Git").
|
|
37
|
+
* @param text - The candidate fragment body.
|
|
38
|
+
* @returns `text` unchanged, or a truncated copy ending in the cut notice — always ≤ the cap.
|
|
39
|
+
*/
|
|
40
|
+
export function capFragment(label: string, text: string): string {
|
|
41
|
+
if (text.length <= FRAGMENT_CHAR_CAP) {
|
|
42
|
+
recordFragment(label, text.length);
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
const totalLen = text.length;
|
|
46
|
+
// Bound the label so a pathological (very long) label can never, by itself,
|
|
47
|
+
// push the suffix — and thus the output — past the cap. Real labels are short
|
|
48
|
+
// internal constants, so this is byte-identical for every actual caller.
|
|
49
|
+
const safeLabel = label.length <= 80 ? label : `${label.slice(0, 77)}...`;
|
|
50
|
+
const suffixFor = (keptLen: number): string =>
|
|
51
|
+
`\n[truncated ${safeLabel}: kept ${keptLen} of ${totalLen} chars — source file unchanged]`;
|
|
52
|
+
// Reserve worst-case suffix size using totalLen's digit count: kept length
|
|
53
|
+
// can only be <= totalLen, so its digit count never exceeds this reserve.
|
|
54
|
+
const reserve = suffixFor(totalLen).length;
|
|
55
|
+
const budget = Math.max(0, FRAGMENT_CHAR_CAP - reserve);
|
|
56
|
+
const slice = text.slice(0, budget);
|
|
57
|
+
const lastNl = slice.lastIndexOf("\n");
|
|
58
|
+
const kept = (lastNl > 0 ? slice.slice(0, lastNl) : slice).trimEnd();
|
|
59
|
+
const result = kept + suffixFor(kept.length);
|
|
60
|
+
recordFragment(label, result.length);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One named fragment's injected size, for {@link budgetReport}. */
|
|
65
|
+
export interface FragmentSize {
|
|
66
|
+
label: string;
|
|
67
|
+
chars: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One-line numeric recap of what a batch of fragments actually injected —
|
|
72
|
+
* the owner-requested visibility so a silent blowup (like the 44k-token
|
|
73
|
+
* lessons block) shows up as a number instead of going unnoticed.
|
|
74
|
+
* @param fragments - The injected fragments (post-cap sizes).
|
|
75
|
+
* @returns e.g. `"injected 5 fragments, 14.2k chars"`, or `"injected 0 fragments"` when empty.
|
|
76
|
+
*/
|
|
77
|
+
export function budgetReport(fragments: FragmentSize[]): string {
|
|
78
|
+
if (fragments.length === 0) return "injected 0 fragments";
|
|
79
|
+
const total = fragments.reduce((sum, f) => sum + f.chars, 0);
|
|
80
|
+
return `injected ${fragments.length} fragments, ${(total / 1000).toFixed(1)}k chars`;
|
|
81
|
+
}
|