@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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module one-shot
|
|
3
|
+
* Sidecar store + gate wiring for the per-gate one-shot metric.
|
|
4
|
+
*
|
|
5
|
+
* STATE — a standalone sidecar (`one-shot.json`) in the same per-project state dir
|
|
6
|
+
* as the session track, mirroring {@link module:deny-loop-store} (atomicWrite,
|
|
7
|
+
* prune-by-window, fail-safe). A write error NEVER changes a gate decision nor its
|
|
8
|
+
* prompt — metrics are pure observation.
|
|
9
|
+
*
|
|
10
|
+
* KEY — the operation identity is content-FREE (`tool + filePath/command`): a fix
|
|
11
|
+
* changes the content, so a content hash would make every retry a new op and hide
|
|
12
|
+
* the deny→allow transition this metric exists to see. The pure model lives in
|
|
13
|
+
* {@link module:one-shot-store}; this file is the only IO surface.
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { atomicWrite } from "../util/json-io";
|
|
19
|
+
import { denyHash } from "../policy/deny-loop";
|
|
20
|
+
import { defaultStateDir } from "../runtime/paths";
|
|
21
|
+
import { applyAllow, applyDeny, EMPTY, formatSummary, pruneState, type OneShotState } from "./one-shot-store";
|
|
22
|
+
import { burstFirst } from "./one-shot-dedup";
|
|
23
|
+
import type { Prompt } from "../prompt/types";
|
|
24
|
+
|
|
25
|
+
/** Sidecar basename under the per-project state dir. */
|
|
26
|
+
export const SIDECAR = "one-shot.json";
|
|
27
|
+
|
|
28
|
+
/** Retention window: 7 days. Aggregates and pending denies older than this are pruned. */
|
|
29
|
+
export const WINDOW_MS: number = 7 * 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Injected clock + state dir (no env var; the gate supplies both). `sessionId` —
|
|
33
|
+
* when present — arms the burst dedup ({@link module:one-shot-dedup}) so the ~11
|
|
34
|
+
* sibling plugin hooks of ONE event count once, not once each. Omitted by
|
|
35
|
+
* mono-process callers/tests → un-deduped historical behaviour.
|
|
36
|
+
*/
|
|
37
|
+
export interface OneShotOpts { now: number; dir: string; sessionId?: string; }
|
|
38
|
+
|
|
39
|
+
/** Identifying tool input (content-free); `content` only decides gateability. */
|
|
40
|
+
export interface OneShotInput { filePath?: string; content?: string; command?: string; }
|
|
41
|
+
|
|
42
|
+
/** Load the state, or a fresh copy when missing/corrupt. */
|
|
43
|
+
export function loadState(path: string): OneShotState {
|
|
44
|
+
try {
|
|
45
|
+
if (!existsSync(path)) return { ...EMPTY };
|
|
46
|
+
const d: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
return d && typeof d === "object" && !Array.isArray(d) ? { ...EMPTY, ...(d as OneShotState) } : { ...EMPTY };
|
|
48
|
+
} catch {
|
|
49
|
+
return { ...EMPTY };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a gate outcome: a `block` is a deny for its gate title; a `null` allow is
|
|
55
|
+
* a fix (if the op was pending) or a one-shot (if gateable). `ask`/`inform` are
|
|
56
|
+
* neither and are skipped. Fails silently — a metric write NEVER affects a decision.
|
|
57
|
+
*
|
|
58
|
+
* The op key is tool-INDEPENDENT (`filePath`/`command` only, constant `"op"` tool):
|
|
59
|
+
* a deny (a `Write`) and its fix (an `Edit`) on the same file must link.
|
|
60
|
+
* @param prompt - The gate's outcome (block, allow=null, or ask/inform).
|
|
61
|
+
* @param input - Identifying tool input (content decides gateability only).
|
|
62
|
+
* @param opts - Clock + state dir.
|
|
63
|
+
*/
|
|
64
|
+
export function recordOneShot(prompt: Prompt | null, input: OneShotInput, opts: OneShotOpts): void {
|
|
65
|
+
try {
|
|
66
|
+
if (prompt && prompt.kind !== "block") return;
|
|
67
|
+
const op = denyHash("op", { filePath: input.filePath, command: input.command });
|
|
68
|
+
if (!burstFirst(op, prompt ? `deny:${prompt.title}` : "allow", opts)) return;
|
|
69
|
+
const path = join(opts.dir, SIDECAR);
|
|
70
|
+
let s = pruneState(loadState(path), opts.now, WINDOW_MS);
|
|
71
|
+
s = prompt
|
|
72
|
+
? applyDeny(s, prompt.title, op, opts.now)
|
|
73
|
+
: applyAllow(s, op, opts.now, input.content != null || input.command != null);
|
|
74
|
+
atomicWrite(path, JSON.stringify(s));
|
|
75
|
+
} catch { /* fail-safe: metrics never change a gate decision */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compact, injection-ready one-shot summary for the project rooted at `cwd`. The
|
|
80
|
+
* state dir is derived EXACTLY like the runtime writer ({@link defaultStateDir},
|
|
81
|
+
* mirroring `handle.ts` `trackFile(sid, defaultStateDir(cwd))`), so the file read
|
|
82
|
+
* here is the same one {@link recordOneShot} wrote. "" when no data or read error.
|
|
83
|
+
* @param cwd - The project working directory (Claude `cwd`), NOT the state dir.
|
|
84
|
+
* @returns One line, e.g. `gates 7d: 88% one-shot (44/50 clean); ...`, or "".
|
|
85
|
+
*/
|
|
86
|
+
export function oneShotSummary(cwd: string): string {
|
|
87
|
+
try {
|
|
88
|
+
const s = pruneState(loadState(join(defaultStateDir(cwd), SIDECAR)), Date.now(), WINDOW_MS);
|
|
89
|
+
return formatSummary(s);
|
|
90
|
+
} catch {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module receipts
|
|
3
|
+
* Verification receipts: capture `tsc`/test runs at PostToolUse and query the
|
|
4
|
+
* freshest PASSING one for the TaskCompleted (hard) and SubagentStop (advisory)
|
|
5
|
+
* gates. A "done" over modified code files is refused unless such a receipt
|
|
6
|
+
* exists — mechanising "no proof, no done".
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import { withTrack, trackJournalEnabled } from "./store";
|
|
10
|
+
import { readTrackSync } from "./track-compact";
|
|
11
|
+
import type { SessionTrack } from "./session-state";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A verification receipt captured at PostToolUse from a Bash verification
|
|
15
|
+
* command. Feeds the TaskCompleted receipt gate: a "done" over modified code
|
|
16
|
+
* files is refused unless a fresh, passing receipt (`exitCode === 0`, `fail === 0`)
|
|
17
|
+
* exists. `pass`/`fail` are parsed only for test runs; `tsc` carries the code alone.
|
|
18
|
+
*/
|
|
19
|
+
export interface Receipt {
|
|
20
|
+
kind: "tsc" | "test";
|
|
21
|
+
exitCode: number;
|
|
22
|
+
pass?: number;
|
|
23
|
+
fail?: number;
|
|
24
|
+
ts: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `tsc` / `bunx tsc` / `npx tsc -p .` — a type-check invocation. */
|
|
28
|
+
const TSC_RE = /(?:^|\s|\/)(?:bunx\s+|npx\s+|pnpm\s+|yarn\s+)?tsc\b/;
|
|
29
|
+
/** `bun test`, `vitest`, `jest`, `npm test`, `npm run test`, … — a test run. */
|
|
30
|
+
const TEST_RE = /\b(?:bun\s+test|vitest|jest|(?:npm|pnpm|yarn)\s+(?:run\s+)?test)\b/;
|
|
31
|
+
|
|
32
|
+
/** Append a verification receipt to the track. Immutable. */
|
|
33
|
+
export function recordReceipt(track: SessionTrack, receipt: Receipt): SessionTrack {
|
|
34
|
+
return { ...track, receipts: [...(track.receipts ?? []), receipt] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse `N pass` / `M fail` counts from bun/vitest/jest output (undefined when absent). */
|
|
38
|
+
function parseCounts(output: string): { pass?: number; fail?: number } {
|
|
39
|
+
const pass = output.match(/(\d+)\s+pass/i);
|
|
40
|
+
const fail = output.match(/(\d+)\s+fail/i);
|
|
41
|
+
return { pass: pass ? Number(pass[1]) : undefined, fail: fail ? Number(fail[1]) : undefined };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Classify a Bash command as a verification receipt, or `null` when it is not a
|
|
46
|
+
* recognised `tsc`/test invocation. Test runs additionally carry parsed
|
|
47
|
+
* pass/fail counts.
|
|
48
|
+
* @param command - The Bash command line.
|
|
49
|
+
* @param output - Combined stdout+stderr (bun writes its summary to stderr).
|
|
50
|
+
* @param exitCode - The command's exit code.
|
|
51
|
+
* @param now - Capture timestamp (epoch ms).
|
|
52
|
+
*/
|
|
53
|
+
export function classifyReceipt(command: string, output: string, exitCode: number, now: number): Receipt | null {
|
|
54
|
+
if (TEST_RE.test(command)) {
|
|
55
|
+
const { pass, fail } = parseCounts(output);
|
|
56
|
+
return { kind: "test", exitCode, pass, fail, ts: now };
|
|
57
|
+
}
|
|
58
|
+
if (TSC_RE.test(command)) return { kind: "tsc", exitCode, ts: now };
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A receipt PROVES success: exit 0 and (for tests) zero reported failures. */
|
|
63
|
+
function isPassing(r: Receipt): boolean {
|
|
64
|
+
return r.exitCode === 0 && (r.fail ?? 0) === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The newest passing receipt within `windowMs`, or `null`. */
|
|
68
|
+
export function freshPassingReceipt(track: SessionTrack, windowMs: number, now: number): Receipt | null {
|
|
69
|
+
const cutoff = now - windowMs;
|
|
70
|
+
const hits = (track.receipts ?? []).filter((r) => r.ts > cutoff && isPassing(r));
|
|
71
|
+
return hits.length ? hits.reduce((a, b) => (b.ts > a.ts ? b : a)) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sync variant reading the track directly (snapshot ⊕ journal) — for the sync
|
|
76
|
+
* gates (TaskCompleted / SubagentStop). Returns the newest passing receipt, or
|
|
77
|
+
* `null` on any read/verify failure (fail-closed: no proof ⇒ unverified).
|
|
78
|
+
*/
|
|
79
|
+
export function freshReceiptFromFile(file: string, windowMs: number, now: number): Receipt | null {
|
|
80
|
+
try {
|
|
81
|
+
return freshPassingReceipt(readTrackSync(file, trackJournalEnabled()), windowMs, now);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Capture a verification receipt from a PostToolUse Bash command into the signed
|
|
89
|
+
* track (best effort — a non-verification command is a no-op).
|
|
90
|
+
*/
|
|
91
|
+
export async function captureReceipt(file: string, command: string, output: string, exitCode: number, now: number): Promise<void> {
|
|
92
|
+
const receipt = classifyReceipt(command, output, exitCode, now);
|
|
93
|
+
if (!receipt) return;
|
|
94
|
+
await withTrack(file, (track) => recordReceipt(track, receipt));
|
|
95
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AuthEntry } from "../freshness/doc-helpers";
|
|
2
|
+
import { creditDocConsultation, type SessionTarget } from "../policy/apex-authorization";
|
|
3
|
+
import type { Receipt } from "./receipts";
|
|
4
|
+
|
|
5
|
+
/** Quality of a recorded agent call (the freshness gate ignores insufficient ones). */
|
|
6
|
+
export type AgentQuality = "sufficient" | "insufficient";
|
|
7
|
+
|
|
8
|
+
/** Recorded session activity that feeds the APEX gates. */
|
|
9
|
+
export interface SessionTrack {
|
|
10
|
+
authorizations: Record<string, AuthEntry>;
|
|
11
|
+
refsRead: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Epoch-ms timestamp of each reference read, keyed by `refsRead` path (parity
|
|
14
|
+
* track-solid-reads.py `solid_reads[].timestamp`). Optional: tracks persisted
|
|
15
|
+
* before this field existed only carry `refsRead`, and such paths count as
|
|
16
|
+
* read (backward compat). PARITY: only the SOLID-read gate TTL-checks these —
|
|
17
|
+
* Python TTL-izes SOLID reads exclusively (require-solid-read.py); the other
|
|
18
|
+
* refsRead consumers (skill-trigger/shadcn/tailwind/design) stay session-scoped.
|
|
19
|
+
*/
|
|
20
|
+
refsReadAt?: Record<string, number>;
|
|
21
|
+
/** Framework awaiting doc credit after a Check-1 deny. PERSISTS — {@link recordDoc} cross-credits it on EVERY consultation, never clears it; only the next deny replaces it via {@link recordTarget} (parity track-doc-consultation.py:62). */
|
|
22
|
+
target?: SessionTarget;
|
|
23
|
+
agents: { name: string; ts: number; quality?: AgentQuality }[];
|
|
24
|
+
trivialEdits: number[];
|
|
25
|
+
brainstormRequired?: boolean;
|
|
26
|
+
/** Verification receipts (tsc/test) at PostToolUse; absent/empty reads as unverified in the TaskCompleted gate (backward compat, fail-closed). See {@link Receipt}. */
|
|
27
|
+
receipts?: Receipt[];
|
|
28
|
+
}
|
|
29
|
+
/** A fresh, empty track. */
|
|
30
|
+
export function emptyTrack(): SessionTrack {
|
|
31
|
+
return { authorizations: {}, refsRead: [], agents: [], trivialEdits: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Record a doc consultation (Context7/Exa/web) for a framework in this session:
|
|
36
|
+
* the Check-2 credit (`doc_sessions` + `sources`) plus the Check-1 stamp
|
|
37
|
+
* (`sessions` + `doc_consulted` = ISO of `now`). PARITY track-doc-consultation.py
|
|
38
|
+
* :62-70 — a `target` left by a Check-1 deny (enforce-apex-phases.ts:80) is
|
|
39
|
+
* cross-credited on EVERY consultation, with NO TTL on `target.set_at`, and the
|
|
40
|
+
* target PERSISTS (only the next deny replaces it via {@link recordTarget});
|
|
41
|
+
* single-shot clearing or TTL-gating the target re-opened the deny loop when a
|
|
42
|
+
* consultation landed > TTL after the deny. Immutable.
|
|
43
|
+
*/
|
|
44
|
+
export function recordDoc(track: SessionTrack, framework: string, sessionId: string, source: string, now: number = Date.now()): SessionTrack {
|
|
45
|
+
const nowIso = new Date(now).toISOString();
|
|
46
|
+
const authorizations = { ...track.authorizations, [framework]: creditDocConsultation(track.authorizations[framework], sessionId, source, nowIso) };
|
|
47
|
+
const t = track.target;
|
|
48
|
+
if (t && t.framework !== framework) {
|
|
49
|
+
authorizations[t.framework] = creditDocConsultation(track.authorizations[t.framework], sessionId, source, nowIso);
|
|
50
|
+
}
|
|
51
|
+
return { ...track, authorizations };
|
|
52
|
+
}
|
|
53
|
+
/** Set the pending doc-credit target (written by the runtime on a Check-1 deny). Immutable. */
|
|
54
|
+
export function recordTarget(track: SessionTrack, target: SessionTarget): SessionTrack {
|
|
55
|
+
return { ...track, target };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record that a SOLID reference file was read (deduped). Immutable. When `now`
|
|
60
|
+
* (epoch ms — the tool event's own timestamp, never a fresh `Date.now()`) is
|
|
61
|
+
* supplied, the read is stamped in `refsReadAt`, refreshed on re-reads so the
|
|
62
|
+
* LATEST read drives the SOLID TTL (parity track-solid-reads.py, which appends
|
|
63
|
+
* a timestamped entry per read; require-solid-read.py checks the most recent).
|
|
64
|
+
* Callers that omit `now` keep the legacy untimestamped behavior.
|
|
65
|
+
*/
|
|
66
|
+
export function recordRefRead(track: SessionTrack, path: string, now?: number): SessionTrack {
|
|
67
|
+
const refsRead = track.refsRead.includes(path) ? track.refsRead : [...track.refsRead, path];
|
|
68
|
+
if (now === undefined) return refsRead === track.refsRead ? track : { ...track, refsRead };
|
|
69
|
+
return { ...track, refsRead, refsReadAt: { ...track.refsReadAt, [path]: now } };
|
|
70
|
+
}
|
|
71
|
+
/** Record an agent/tool call with a timestamp + optional quality. Immutable. */
|
|
72
|
+
export function recordAgent(track: SessionTrack, name: string, ts: number, quality?: AgentQuality): SessionTrack {
|
|
73
|
+
const entry = quality ? { name, ts, quality } : { name, ts };
|
|
74
|
+
return { ...track, agents: [...track.agents, entry] };
|
|
75
|
+
}
|
|
76
|
+
/** True when ALL of `names` ran within `windowMs` with non-insufficient quality. */
|
|
77
|
+
export function agentsFresh(track: SessionTrack, names: string[], windowMs: number, now: number): boolean {
|
|
78
|
+
const cutoff = now - windowMs;
|
|
79
|
+
return names.every((n) => track.agents.some((a) => a.name === n && a.ts > cutoff && a.quality !== "insufficient"));
|
|
80
|
+
}
|
|
81
|
+
/** Record a trivial edit timestamp (sliding window; old evicted). Immutable. */
|
|
82
|
+
export function recordTrivialEdit(track: SessionTrack, ts: number, windowMs: number, now: number): SessionTrack {
|
|
83
|
+
const cutoff = now - windowMs;
|
|
84
|
+
return { ...track, trivialEdits: [...(track.trivialEdits ?? []).filter((t) => t > cutoff), ts] };
|
|
85
|
+
}
|
|
86
|
+
/** Count trivial edits within the sliding window. */
|
|
87
|
+
export function trivialCount(track: SessionTrack, windowMs: number, now: number): number {
|
|
88
|
+
const cutoff = now - windowMs;
|
|
89
|
+
return (track.trivialEdits ?? []).filter((t) => t > cutoff).length;
|
|
90
|
+
}
|
|
91
|
+
/** Set the brainstorm-required flag (from creation-intent detection). Immutable. */
|
|
92
|
+
export function recordBrainstormRequired(track: SessionTrack, required: boolean): SessionTrack {
|
|
93
|
+
return { ...track, brainstormRequired: required };
|
|
94
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module store
|
|
3
|
+
* Session-track persistence — TWO modes behind an env kill-switch:
|
|
4
|
+
* - JOURNAL (default): writes append signed event lines to `track-<sid>.log`
|
|
5
|
+
* (O_APPEND, lock-free — see track-journal.ts), reads fold the log over the
|
|
6
|
+
* legacy `track-<sid>.json` snapshot. No lock on the hot path ⇒ the fan-out
|
|
7
|
+
* can no longer lose a write to "track lock busy, write skipped" — compaction
|
|
8
|
+
* included (it is rename-atomic, see track-compact.ts).
|
|
9
|
+
* - LEGACY (`FUSE_TRACK_JOURNAL=0`): the original locked read-modify-write,
|
|
10
|
+
* kept fully intact below as the instant rollback (G1) — no redeploy needed.
|
|
11
|
+
* NOTE: =0 does NOT honour the uncompacted journal (the track reads as its
|
|
12
|
+
* last snapshot, possibly EMPTY); the events are recoverable by switching
|
|
13
|
+
* back to journal mode — never delete a `.log` during a =0 rollback.
|
|
14
|
+
* The flag is read at CALL time so the switch is live.
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
import { readJsonFile, writeJsonFile } from "../util/json-io";
|
|
18
|
+
import { emptyTrack, type SessionTrack } from "./session-state";
|
|
19
|
+
import { signTrack, verifyTrack, writeLastNonce, type TrackEnvelope } from "./integrity";
|
|
20
|
+
import { LOCK_FAILED, withTrackLock } from "./track-lock";
|
|
21
|
+
import { dirname } from "node:path";
|
|
22
|
+
import { appendEvent, foldEvents } from "./track-journal";
|
|
23
|
+
import { diffTrackEvents } from "./track-diff";
|
|
24
|
+
import { journalLogPath, maybeCompactJournal, readEvents } from "./track-compact";
|
|
25
|
+
|
|
26
|
+
/** Journal mode is the default; `FUSE_TRACK_JOURNAL=0` reverts to the legacy locked RMW (see the module NOTE: =0 ignores the uncompacted journal — recoverable, keep the `.log`). */
|
|
27
|
+
export function trackJournalEnabled(): boolean {
|
|
28
|
+
return process.env.FUSE_TRACK_JOURNAL !== "0";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Verified legacy snapshot (fail closed: absent/corrupt/tampered → emptyTrack). */
|
|
32
|
+
async function loadSnapshot(file: string): Promise<SessionTrack> {
|
|
33
|
+
const envelope = await readJsonFile<TrackEnvelope>(file);
|
|
34
|
+
if (!envelope) return emptyTrack();
|
|
35
|
+
return verifyTrack(envelope) ?? emptyTrack();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load a session track: the verified legacy snapshot (the migration base — an
|
|
40
|
+
* old `track-<sid>.json` with no log reads exactly as before) with the journal
|
|
41
|
+
* folded on top. Fail closed on tampering (envelope MAC, per-line MAC).
|
|
42
|
+
*/
|
|
43
|
+
export async function loadTrack(file: string): Promise<SessionTrack> {
|
|
44
|
+
const base = await loadSnapshot(file);
|
|
45
|
+
if (!trackJournalEnabled()) return base;
|
|
46
|
+
return foldEvents(readEvents(journalLogPath(file)), base);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Journal-mode write: append the mutation's events, then maybe compact. */
|
|
50
|
+
async function appendDiff(file: string, prev: SessionTrack, next: SessionTrack): Promise<boolean> {
|
|
51
|
+
const log = journalLogPath(file);
|
|
52
|
+
let ok = true;
|
|
53
|
+
for (const ev of diffTrackEvents(prev, next, Date.now())) ok = appendEvent(log, ev.field, ev.op, ev.value, ev.ts) && ok;
|
|
54
|
+
await maybeCompactJournal(file);
|
|
55
|
+
return ok;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persist a session track. Journal mode appends the diff as signed events;
|
|
60
|
+
* legacy mode signs and persists the tamper-evident envelope, then writes the
|
|
61
|
+
* advisory nonce watermark.
|
|
62
|
+
*/
|
|
63
|
+
export async function saveTrack(file: string, track: SessionTrack): Promise<void> {
|
|
64
|
+
if (trackJournalEnabled()) {
|
|
65
|
+
await appendDiff(file, await loadTrack(file), track);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const envelope = signTrack(track);
|
|
69
|
+
await writeJsonFile(file, envelope);
|
|
70
|
+
writeLastNonce(envelope.nonce);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mutate a session track. Journal mode: load (snapshot ⊕ log), run `mutate`,
|
|
75
|
+
* append the resulting events — lock-free, so a write is only ever lost to a
|
|
76
|
+
* genuine I/O error (reported as `false`). Legacy mode: locked RMW; on lock
|
|
77
|
+
* contention the write is SKIPPED (fail-open, stderr logged) and `false` is
|
|
78
|
+
* returned — a named lost write, never a crash.
|
|
79
|
+
* @param file - The session track file.
|
|
80
|
+
* @param mutate - Pure mutation over the freshly loaded track.
|
|
81
|
+
* @returns True when the write landed.
|
|
82
|
+
*/
|
|
83
|
+
export async function withTrack(
|
|
84
|
+
file: string,
|
|
85
|
+
mutate: (track: SessionTrack) => SessionTrack | Promise<SessionTrack>,
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
if (trackJournalEnabled()) {
|
|
88
|
+
const track = await loadTrack(file);
|
|
89
|
+
return appendDiff(file, track, await mutate(track));
|
|
90
|
+
}
|
|
91
|
+
const ran = await withTrackLock(dirname(file), async () => {
|
|
92
|
+
const track = await loadTrack(file);
|
|
93
|
+
await saveTrack(file, await mutate(track));
|
|
94
|
+
});
|
|
95
|
+
return ran !== LOCK_FAILED;
|
|
96
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module track-compact
|
|
3
|
+
* Read side + compaction for the track journal (see track-journal.ts):
|
|
4
|
+
* - {@link parseEvents}/{@link readEvents}: MAC-verified replay — a tampered
|
|
5
|
+
* line is dropped, never the whole file (fail-closed PER LINE);
|
|
6
|
+
* - {@link readTrackSync}: the sync snapshot ⊕ journal read for the sync gates;
|
|
7
|
+
* - {@link maybeCompactJournal}: past {@link COMPACT_BYTES}, fold the log into
|
|
8
|
+
* the signed snapshot — RENAME-ATOMIC (logrotate pattern) under the EXISTING
|
|
9
|
+
* track lock (rare, skipped on contention). Lock-free appends stay SAFE in
|
|
10
|
+
* both race windows: before the rename → captured by the fold; after → a
|
|
11
|
+
* fresh log at the original path (appendFileSync keeps no fd across calls).
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
import { atomicWrite } from "../util/json-io";
|
|
17
|
+
import { emptyTrack, type SessionTrack } from "./session-state";
|
|
18
|
+
import { computeMac, loadOrCreateKey, signTrack, verifyTrack, writeLastNonce, type TrackEnvelope } from "./integrity";
|
|
19
|
+
import { foldEvents, type TrackEvent } from "./track-journal";
|
|
20
|
+
import { withTrackLock } from "./track-lock";
|
|
21
|
+
|
|
22
|
+
/** Compact when the log exceeds this size (bytes). */
|
|
23
|
+
export const COMPACT_BYTES: number = 128 * 1024;
|
|
24
|
+
|
|
25
|
+
/** The journal log path twin of a track snapshot path. */
|
|
26
|
+
export function journalLogPath(trackPath: string): string {
|
|
27
|
+
return trackPath.replace(/\.json$/, ".log");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse & MAC-verify journal text; malformed or tampered lines are skipped. */
|
|
31
|
+
export function parseEvents(text: string): TrackEvent[] {
|
|
32
|
+
const key = loadOrCreateKey(), out: TrackEvent[] = [];
|
|
33
|
+
for (const line of text.split("\n")) {
|
|
34
|
+
if (!line) continue;
|
|
35
|
+
try {
|
|
36
|
+
const ev = JSON.parse(line) as TrackEvent;
|
|
37
|
+
if (ev?.v !== 1 || typeof ev.nonce !== "string" || typeof ev.ts !== "number") continue;
|
|
38
|
+
if (ev.mac !== computeMac(key, JSON.stringify({ field: ev.field, op: ev.op, value: ev.value, ts: ev.ts }), ev.nonce)) continue; // fail-closed per line
|
|
39
|
+
out.push(ev);
|
|
40
|
+
} catch { /* skip the bad line, keep the rest */ }
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read & verify every event of a log; absent/unreadable → [] (fail-open read). */
|
|
46
|
+
export function readEvents(logPath: string): TrackEvent[] {
|
|
47
|
+
try { return parseEvents(readFileSync(logPath, "utf8")); } catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Verified legacy snapshot only (fail-closed → emptyTrack), sync. */
|
|
51
|
+
function readSnapshotSync(file: string): SessionTrack {
|
|
52
|
+
try {
|
|
53
|
+
return verifyTrack(JSON.parse(readFileSync(file, "utf8")) as TrackEnvelope) ?? emptyTrack();
|
|
54
|
+
} catch {
|
|
55
|
+
return emptyTrack();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sync snapshot ⊕ journal read for the sync gates; `journal=false` = legacy kill-switch path. */
|
|
60
|
+
export function readTrackSync(file: string, journal: boolean): SessionTrack {
|
|
61
|
+
const base = readSnapshotSync(file);
|
|
62
|
+
return journal ? foldEvents(readEvents(journalLogPath(file)), base) : base;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fold a whole log file into the signed snapshot (signTrack + nonce, unchanged). */
|
|
66
|
+
function foldIntoSnapshot(file: string, logPath: string): void {
|
|
67
|
+
const envelope = signTrack(foldEvents(readEvents(logPath), readSnapshotSync(file)));
|
|
68
|
+
atomicWrite(file, JSON.stringify(envelope, null, 2));
|
|
69
|
+
writeLastNonce(envelope.nonce);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Rename-atomic compaction: rename captures the WHOLE log, fold, unlink. A crash leaves `.folding` (recovered first next run); on fold failure BEFORE commit the log is renamed back — never a lost event, and never an event in BOTH snapshot and log (no double-count). */
|
|
73
|
+
function compactSync(file: string): void {
|
|
74
|
+
const log = journalLogPath(file), folding = `${log}.folding`;
|
|
75
|
+
if (existsSync(folding)) { foldIntoSnapshot(file, folding); unlinkSync(folding); } // crashed compaction recovery
|
|
76
|
+
renameSync(log, folding); // atomic capture; the next append recreates a fresh log
|
|
77
|
+
let committed = false;
|
|
78
|
+
try {
|
|
79
|
+
foldIntoSnapshot(file, folding);
|
|
80
|
+
committed = true; // the snapshot now HOLDS the events: never return them to the log
|
|
81
|
+
unlinkSync(folding);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (!committed) { try { renameSync(folding, log); } catch { /* keep .folding for recovery */ } }
|
|
84
|
+
else { try { unlinkSync(folding); } catch { /* residue iff unlink itself failed */ } }
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Trigger compaction past the cap (`FUSE_TRACK_COMPACT_BYTES` overrides {@link COMPACT_BYTES}), under the existing lock (skipped on contention). */
|
|
90
|
+
export async function maybeCompactJournal(file: string): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
const cap = Number(process.env.FUSE_TRACK_COMPACT_BYTES) || COMPACT_BYTES;
|
|
93
|
+
if (statSync(journalLogPath(file)).size < cap) return;
|
|
94
|
+
} catch {
|
|
95
|
+
return; // no log yet
|
|
96
|
+
}
|
|
97
|
+
await withTrackLock(dirname(file), async () => {
|
|
98
|
+
try { compactSync(file); } catch { /* rare path: the next trigger retries */ }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module track-diff
|
|
3
|
+
* Derive journal events from a track mutation (prev → next), so journal-mode
|
|
4
|
+
* writers keep the existing immutable-mutator API unchanged: run the pure
|
|
5
|
+
* mutator in memory, then append ONLY what changed. The per-field mapping
|
|
6
|
+
* mirrors the fold table in track-journal.ts (adds/maxes/appends/merges/sets;
|
|
7
|
+
* evictions are NOT events — the fold's sliding-window re-filter owns those).
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import type { SessionTrack } from "./session-state";
|
|
11
|
+
|
|
12
|
+
/** An unsigned event ready for {@link appendEvent}. */
|
|
13
|
+
export type RawEvent = { field: string; op: "add" | "max" | "append" | "merge" | "set"; value: unknown; ts: number };
|
|
14
|
+
|
|
15
|
+
/** Elements of `next` beyond the longest prefix shared with `prev` (append-only arrays). */
|
|
16
|
+
function tail<T>(prev: T[], next: T[]): T[] {
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < prev.length && i < next.length && JSON.stringify(prev[i]) === JSON.stringify(next[i])) i++;
|
|
19
|
+
return next.slice(i);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Multiset of added numbers (next counts minus prev counts). */
|
|
23
|
+
function added(xs: number[], ys: number[]): number[] {
|
|
24
|
+
const left = new Map<number, number>();
|
|
25
|
+
for (const x of xs) left.set(x, (left.get(x) ?? 0) + 1);
|
|
26
|
+
const out: number[] = [];
|
|
27
|
+
for (const y of ys) {
|
|
28
|
+
const n = left.get(y) ?? 0;
|
|
29
|
+
if (n > 0) left.set(y, n - 1);
|
|
30
|
+
else out.push(y);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Events replaying `next` over `prev` (empty when the mutation was a no-op). */
|
|
36
|
+
export function diffTrackEvents(prev: SessionTrack, next: SessionTrack, now: number): RawEvent[] {
|
|
37
|
+
if (prev === next) return [];
|
|
38
|
+
const out: RawEvent[] = [];
|
|
39
|
+
for (const p of next.refsRead) if (!prev.refsRead.includes(p)) out.push({ field: "refsRead", op: "add", value: p, ts: next.refsReadAt?.[p] ?? now });
|
|
40
|
+
for (const [p, ts] of Object.entries(next.refsReadAt ?? {})) if (ts !== prev.refsReadAt?.[p]) out.push({ field: "refsReadAt", op: "max", value: [p, ts], ts });
|
|
41
|
+
for (const a of tail(prev.agents, next.agents)) out.push({ field: "agents", op: "append", value: a, ts: a.ts ?? now });
|
|
42
|
+
for (const r of tail(prev.receipts ?? [], next.receipts ?? [])) out.push({ field: "receipts", op: "append", value: r, ts: r.ts ?? now });
|
|
43
|
+
for (const [key, entry] of Object.entries(next.authorizations)) {
|
|
44
|
+
if (JSON.stringify(entry) !== JSON.stringify(prev.authorizations[key])) out.push({ field: "authorizations", op: "merge", value: { key, entry }, ts: Date.parse(entry.doc_consulted ?? "") || now });
|
|
45
|
+
}
|
|
46
|
+
for (const ts of added(prev.trivialEdits ?? [], next.trivialEdits ?? [])) out.push({ field: "trivialEdits", op: "add", value: ts, ts });
|
|
47
|
+
if (next.target && JSON.stringify(next.target) !== JSON.stringify(prev.target)) out.push({ field: "target", op: "set", value: next.target, ts: Date.parse(next.target.set_at) || now });
|
|
48
|
+
if (next.brainstormRequired !== prev.brainstormRequired) out.push({ field: "brainstormRequired", op: "set", value: next.brainstormRequired, ts: now });
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module track-journal
|
|
3
|
+
* Append-only, per-line-signed event journal for {@link SessionTrack} — the
|
|
4
|
+
* fan-out-immune replacement for the locked RMW (generalises the freshness/
|
|
5
|
+
* ref-journal.ts pattern to every field). Appends serialise on a SHORT
|
|
6
|
+
* BLOCKING track lock (never skipped, sub-ms wait — see appendEvent) so they
|
|
7
|
+
* can never race the rename-atomic compaction: zero lost write. Per-line HMAC
|
|
8
|
+
* (same key/scheme as integrity.ts): a tampered line is dropped, never the
|
|
9
|
+
* whole file (fail-closed PER LINE).
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { computeMac, loadOrCreateKey } from "./integrity";
|
|
16
|
+
import { withTrackLockSyncBlocking } from "./track-lock-sync";
|
|
17
|
+
import { emptyTrack, type SessionTrack } from "./session-state";
|
|
18
|
+
import type { AuthEntry } from "../freshness/doc-helpers";
|
|
19
|
+
import type { SessionTarget } from "../policy/apex-authorization";
|
|
20
|
+
import type { Receipt } from "./receipts";
|
|
21
|
+
|
|
22
|
+
/** One signed journal line: `mac` = HMAC over `"<nonce>:<JSON{field,op,value,ts}>"`. */
|
|
23
|
+
export type TrackEvent = { v: 1; field: string; op: "add" | "max" | "append" | "merge" | "set"; value: unknown; ts: number; nonce: string; mac: string };
|
|
24
|
+
|
|
25
|
+
/** Hard cap per line — short lines stay non-interleaved under O_APPEND. */
|
|
26
|
+
const MAX_LINE = 32 * 1024;
|
|
27
|
+
/** Trivial-edit sliding window for the fold (mirrors gate.ts DEFAULT_WINDOW_MS). */
|
|
28
|
+
const TRIVIAL_WINDOW_MS = 120_000;
|
|
29
|
+
|
|
30
|
+
/** Sign one event: an oversized STRING value is truncated then re-signed (never an unsigned partial line); an oversized non-string is dropped (null). */
|
|
31
|
+
export function signEvent(field: string, op: TrackEvent["op"], value: unknown, ts: number): TrackEvent | null {
|
|
32
|
+
let data = JSON.stringify({ field, op, value, ts });
|
|
33
|
+
if (data.length > MAX_LINE - 256) {
|
|
34
|
+
if (typeof value !== "string") return null;
|
|
35
|
+
value = value.slice(0, Math.max(0, value.length - (data.length - (MAX_LINE - 256))));
|
|
36
|
+
data = JSON.stringify({ field, op, value, ts });
|
|
37
|
+
}
|
|
38
|
+
const nonce = randomBytes(8).toString("hex");
|
|
39
|
+
return { v: 1, field, op, value, ts, nonce, mac: computeMac(loadOrCreateKey(), data, nonce) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Append one signed event line under the BLOCKING track lock (same `track.lock` as the compaction — an append can never straddle rename/fold/unlink; never skipped). Fail-open on I/O error. */
|
|
43
|
+
export function appendEvent(logPath: string, field: string, op: TrackEvent["op"], value: unknown, ts: number): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const ev = signEvent(field, op, value, ts);
|
|
46
|
+
if (!ev) return false;
|
|
47
|
+
withTrackLockSyncBlocking(dirname(logPath), () => appendFileSync(logPath, JSON.stringify(ev) + "\n", "utf8"));
|
|
48
|
+
return true;
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const union = (a?: string[], b?: string[]): string[] => [...new Set([...(a ?? []), ...(b ?? [])])];
|
|
53
|
+
|
|
54
|
+
/** Merge one auth entry: non-empty list fields union, scalars of the later-folded (newer) event win. */
|
|
55
|
+
function mergeAuth(p: AuthEntry | undefined, e: AuthEntry): AuthEntry {
|
|
56
|
+
const b = p ?? {}, out: AuthEntry = { ...b, ...e };
|
|
57
|
+
const sessions = union(b.sessions, e.sessions);
|
|
58
|
+
if (sessions.length) out.sessions = sessions;
|
|
59
|
+
const docSessions = union(b.doc_sessions, e.doc_sessions);
|
|
60
|
+
if (docSessions.length) out.doc_sessions = docSessions;
|
|
61
|
+
const sources = union(b.sources ?? (b.source ? [b.source] : []), e.sources ?? (e.source ? [e.source] : []));
|
|
62
|
+
if (sources.length) out.sources = sources;
|
|
63
|
+
const readPaths = union(b.read_paths, e.read_paths);
|
|
64
|
+
if (readPaths.length) out.read_paths = readPaths;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replay events over `base` (the legacy snapshot — emptyTrack when absent).
|
|
70
|
+
* Deterministic CRDT fold with a STABLE sort on ts (log order breaks ties):
|
|
71
|
+
* refsRead = dedup union; refsReadAt = per-key max; agents/receipts =
|
|
72
|
+
* ts-ordered append, never deduped; authorizations = per-key merge (list
|
|
73
|
+
* unions); trivialEdits = union re-filtered by the sliding window;
|
|
74
|
+
* target/brainstormRequired = last-writer-wins on ts.
|
|
75
|
+
*/
|
|
76
|
+
export function foldEvents(events: TrackEvent[], base: SessionTrack = emptyTrack()): SessionTrack {
|
|
77
|
+
const t: SessionTrack = { ...base, refsRead: [...base.refsRead], agents: [...base.agents], authorizations: { ...base.authorizations }, trivialEdits: [] };
|
|
78
|
+
const at: Record<string, number> = { ...base.refsReadAt };
|
|
79
|
+
const receipts: Receipt[] = [...(base.receipts ?? [])];
|
|
80
|
+
const trivial = new Set(base.trivialEdits ?? []);
|
|
81
|
+
let targetTs = base.target ? Date.parse(base.target.set_at) || 0 : 0, brainstormTs = 0; // a base flag is unstamped: any event overrides it
|
|
82
|
+
for (const ev of [...events].sort((a, b) => a.ts - b.ts)) { // stable: log order breaks ts ties
|
|
83
|
+
if (ev.field === "refsRead") { const p = String(ev.value); if (!t.refsRead.includes(p)) t.refsRead.push(p); }
|
|
84
|
+
else if (ev.field === "refsReadAt") { const [p, ts] = ev.value as [string, number]; if ((at[p] ?? -1) < ts) at[p] = ts; }
|
|
85
|
+
else if (ev.field === "agents") t.agents.push(ev.value as SessionTrack["agents"][number]);
|
|
86
|
+
else if (ev.field === "receipts") receipts.push(ev.value as Receipt);
|
|
87
|
+
else if (ev.field === "authorizations") { const m = ev.value as { key: string; entry: AuthEntry }; t.authorizations[m.key] = mergeAuth(t.authorizations[m.key], m.entry); }
|
|
88
|
+
else if (ev.field === "trivialEdits") trivial.add(Number(ev.value));
|
|
89
|
+
else if (ev.field === "target" && ev.ts >= targetTs) { t.target = ev.value as SessionTarget; targetTs = ev.ts; }
|
|
90
|
+
else if (ev.field === "brainstormRequired" && ev.ts >= brainstormTs) { t.brainstormRequired = Boolean(ev.value); brainstormTs = ev.ts; }
|
|
91
|
+
}
|
|
92
|
+
if (Object.keys(at).length) t.refsReadAt = at;
|
|
93
|
+
if (receipts.length) t.receipts = receipts;
|
|
94
|
+
const maxT = Math.max(0, ...trivial);
|
|
95
|
+
t.trivialEdits = [...trivial].filter((x) => x > maxT - TRIVIAL_WINDOW_MS).sort((a, b) => a - b);
|
|
96
|
+
return t;
|
|
97
|
+
}
|