From b39b133cc23c09e14f980c99d30907062601688d Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 06:08:02 +0000 Subject: [PATCH 1/6] fix: bundle OMO component hook CLIs --- .../comment-checker/dist/apply-patch.d.ts | 7 + .../comment-checker/dist/apply-patch.js | 173 +++++++++++ .../components/comment-checker/dist/cli.d.ts | 2 + .../components/comment-checker/dist/cli.js | 10 + .../comment-checker/dist/codex-hook.d.ts | 22 ++ .../comment-checker/dist/codex-hook.js | 165 ++++++++++ .../comment-checker/dist/core-values.d.ts | 1 + .../comment-checker/dist/core-values.js | 1 + .../components/comment-checker/dist/core.d.ts | 5 + .../components/comment-checker/dist/core.js | 4 + .../comment-checker/dist/hook-input.d.ts | 6 + .../comment-checker/dist/hook-input.js | 10 + .../comment-checker/dist/record.d.ts | 2 + .../components/comment-checker/dist/record.js | 11 + .../dist/request-extractor.d.ts | 3 + .../comment-checker/dist/request-extractor.js | 104 +++++++ .../comment-checker/dist/runner.d.ts | 26 ++ .../components/comment-checker/dist/runner.js | 144 +++++++++ .../comment-checker/dist/types.d.ts | 43 +++ .../components/comment-checker/dist/types.js | 1 + plugins/omo/components/git-bash/dist/cli.d.ts | 2 + plugins/omo/components/git-bash/dist/cli.js | 29 ++ .../components/git-bash/dist/codex-hook.d.ts | 28 ++ .../components/git-bash/dist/codex-hook.js | 137 ++++++++ .../omo/components/git-bash/dist/index.d.ts | 1 + plugins/omo/components/git-bash/dist/index.js | 1 + plugins/omo/components/lsp/dist/cli.d.ts | 2 + plugins/omo/components/lsp/dist/cli.js | 42 +++ .../components/lsp/dist/codex-hook-cli.d.ts | 2 + .../omo/components/lsp/dist/codex-hook-cli.js | 40 +++ .../omo/components/lsp/dist/codex-hook.d.ts | 16 + plugins/omo/components/lsp/dist/codex-hook.js | 176 +++++++++++ .../lsp/dist/lsp-session-state.d.ts | 11 + .../components/lsp/dist/lsp-session-state.js | 92 ++++++ .../lsp/dist/mutated-file-paths.d.ts | 6 + .../components/lsp/dist/mutated-file-paths.js | 79 +++++ plugins/omo/components/rules/dist/cli.d.ts | 2 + plugins/omo/components/rules/dist/cli.js | 118 +++++++ .../rules/dist/codex-hook-options.d.ts | 5 + .../rules/dist/codex-hook-options.js | 1 + .../omo/components/rules/dist/codex-hook.d.ts | 47 +++ .../omo/components/rules/dist/codex-hook.js | 125 ++++++++ plugins/omo/components/rules/dist/config.d.ts | 2 + plugins/omo/components/rules/dist/config.js | 89 ++++++ .../rules/dist/context-pressure.d.ts | 2 + .../components/rules/dist/context-pressure.js | 26 ++ .../omo/components/rules/dist/debug-log.d.ts | 8 + .../omo/components/rules/dist/debug-log.js | 36 +++ .../dist/dynamic-target-fingerprints.d.ts | 7 + .../rules/dist/dynamic-target-fingerprints.js | 65 ++++ .../components/rules/dist/hook-output.d.ts | 2 + .../omo/components/rules/dist/hook-output.js | 24 ++ .../omo/components/rules/dist/path-utils.d.ts | 4 + .../omo/components/rules/dist/path-utils.js | 24 ++ .../rules/dist/persistent-cache.d.ts | 13 + .../components/rules/dist/persistent-cache.js | 169 ++++++++++ .../rules/dist/post-compact-budget.d.ts | 6 + .../rules/dist/post-compact-budget.js | 74 +++++ .../rules/dist/post-compact-claim.d.ts | 4 + .../rules/dist/post-compact-claim.js | 6 + .../rules/dist/post-compact-state.d.ts | 13 + .../rules/dist/post-compact-state.js | 29 ++ .../rules/dist/rules-engine-factory.d.ts | 6 + .../rules/dist/rules-engine-factory.js | 20 ++ .../components/rules/dist/rules/cache.d.ts | 9 + .../omo/components/rules/dist/rules/cache.js | 51 +++ .../rules/dist/rules/constants.d.ts | 58 ++++ .../components/rules/dist/rules/constants.js | 89 ++++++ .../dist/rules/engine-dynamic-cache.d.ts | 5 + .../rules/dist/rules/engine-dynamic-cache.js | 60 ++++ .../dist/rules/engine-dynamic-loader.d.ts | 6 + .../rules/dist/rules/engine-dynamic-loader.js | 61 ++++ .../rules/dist/rules/engine-loader.d.ts | 7 + .../rules/dist/rules/engine-loader.js | 60 ++++ .../rules/dist/rules/engine-paths.d.ts | 11 + .../rules/dist/rules/engine-paths.js | 75 +++++ .../dist/rules/engine-static-loader.d.ts | 6 + .../rules/dist/rules/engine-static-loader.js | 29 ++ .../rules/dist/rules/engine-types.d.ts | 44 +++ .../rules/dist/rules/engine-types.js | 1 + .../components/rules/dist/rules/engine.d.ts | 5 + .../omo/components/rules/dist/rules/engine.js | 81 +++++ .../components/rules/dist/rules/errors.d.ts | 6 + .../omo/components/rules/dist/rules/errors.js | 12 + .../rules/dist/rules/finder-cache.d.ts | 14 + .../rules/dist/rules/finder-cache.js | 51 +++ .../rules/dist/rules/finder-paths.d.ts | 6 + .../rules/dist/rules/finder-paths.js | 33 ++ .../rules/dist/rules/finder-sources.d.ts | 5 + .../rules/dist/rules/finder-sources.js | 40 +++ .../components/rules/dist/rules/finder.d.ts | 28 ++ .../omo/components/rules/dist/rules/finder.js | 146 +++++++++ .../rules/dist/rules/formatter.d.ts | 7 + .../components/rules/dist/rules/formatter.js | 112 +++++++ .../components/rules/dist/rules/matcher.d.ts | 18 ++ .../components/rules/dist/rules/matcher.js | 93 ++++++ .../components/rules/dist/rules/ordering.d.ts | 3 + .../components/rules/dist/rules/ordering.js | 27 ++ .../rules/dist/rules/parser-frontmatter.d.ts | 7 + .../rules/dist/rules/parser-frontmatter.js | 30 ++ .../rules/dist/rules/parser-yaml.d.ts | 2 + .../rules/dist/rules/parser-yaml.js | 237 ++++++++++++++ .../components/rules/dist/rules/parser.d.ts | 3 + .../omo/components/rules/dist/rules/parser.js | 31 ++ .../rules/dist/rules/plugin-root.d.ts | 1 + .../rules/dist/rules/plugin-root.js | 48 +++ .../rules/dist/rules/project-root.d.ts | 1 + .../rules/dist/rules/project-root.js | 23 ++ .../components/rules/dist/rules/scanner.d.ts | 14 + .../components/rules/dist/rules/scanner.js | 111 +++++++ .../components/rules/dist/rules/sources.d.ts | 3 + .../components/rules/dist/rules/sources.js | 9 + .../rules/dist/rules/truncator.d.ts | 17 + .../components/rules/dist/rules/truncator.js | 45 +++ .../components/rules/dist/rules/types.d.ts | 122 ++++++++ .../omo/components/rules/dist/rules/types.js | 8 + .../rules/dist/session-state-lock.d.ts | 3 + .../rules/dist/session-state-lock.js | 41 +++ .../rules/dist/sparkshell-awareness.d.ts | 5 + .../rules/dist/sparkshell-awareness.js | 45 +++ .../rules/dist/static-injection.d.ts | 3 + .../components/rules/dist/static-injection.js | 45 +++ .../omo/components/rules/dist/tool-paths.d.ts | 6 + .../omo/components/rules/dist/tool-paths.js | 168 ++++++++++ .../rules/dist/transcript-rule-filter.d.ts | 3 + .../rules/dist/transcript-rule-filter.js | 46 +++ .../rules/dist/transcript-search.d.ts | 4 + .../rules/dist/transcript-search.js | 91 ++++++ .../dist/boulder-reader.d.ts | 16 + .../dist/boulder-reader.js | 146 +++++++++ .../start-work-continuation/dist/cli.d.ts | 2 + .../start-work-continuation/dist/cli.js | 49 +++ .../dist/codex-hook.d.ts | 2 + .../dist/codex-hook.js | 80 +++++ .../dist/directive.d.ts | 1 + .../start-work-continuation/dist/directive.js | 2 + .../start-work-continuation/dist/index.d.ts | 5 + .../start-work-continuation/dist/index.js | 3 + .../start-work-continuation/dist/types.d.ts | 20 ++ .../start-work-continuation/dist/types.js | 1 + .../telemetry/dist/atomic-write.d.ts | 1 + .../components/telemetry/dist/atomic-write.js | 18 ++ .../omo/components/telemetry/dist/cli.d.ts | 2 + plugins/omo/components/telemetry/dist/cli.js | 62 ++++ .../components/telemetry/dist/codex-hook.d.ts | 15 + .../components/telemetry/dist/codex-hook.js | 42 +++ .../components/telemetry/dist/data-path.d.ts | 10 + .../components/telemetry/dist/data-path.js | 35 +++ .../telemetry/dist/diagnostics.d.ts | 12 + .../components/telemetry/dist/diagnostics.js | 108 +++++++ .../components/telemetry/dist/env-flags.d.ts | 4 + .../components/telemetry/dist/env-flags.js | 31 ++ .../dist/posthog-activity-state.d.ts | 8 + .../telemetry/dist/posthog-activity-state.js | 68 ++++ .../components/telemetry/dist/posthog.d.ts | 21 ++ .../omo/components/telemetry/dist/posthog.js | 133 ++++++++ .../telemetry/dist/product-identity.d.ts | 8 + .../telemetry/dist/product-identity.js | 29 ++ .../omo/components/ultrawork/dist/cli.d.ts | 2 + plugins/omo/components/ultrawork/dist/cli.js | 48 +++ .../components/ultrawork/dist/codex-hook.d.ts | 7 + .../components/ultrawork/dist/codex-hook.js | 122 ++++++++ .../components/ultrawork/dist/directive.d.ts | 1 + .../components/ultrawork/dist/directive.js | 2 + .../components/ulw-loop/dist/checkpoint.d.ts | 16 + .../components/ulw-loop/dist/checkpoint.js | 200 ++++++++++++ .../ulw-loop/dist/cli-arg-parser.d.ts | 17 + .../ulw-loop/dist/cli-arg-parser.js | 97 ++++++ .../ulw-loop/dist/cli-commands.d.ts | 1 + .../components/ulw-loop/dist/cli-commands.js | 175 +++++++++++ .../components/ulw-loop/dist/cli-output.d.ts | 6 + .../components/ulw-loop/dist/cli-output.js | 55 ++++ .../ulw-loop/dist/cli-steering.d.ts | 12 + .../components/ulw-loop/dist/cli-steering.js | 145 +++++++++ plugins/omo/components/ulw-loop/dist/cli.d.ts | 2 + plugins/omo/components/ulw-loop/dist/cli.js | 37 +++ .../ulw-loop/dist/codex-goal-instruction.d.ts | 13 + .../ulw-loop/dist/codex-goal-instruction.js | 100 ++++++ .../ulw-loop/dist/codex-goal-snapshot.d.ts | 26 ++ .../ulw-loop/dist/codex-goal-snapshot.js | 97 ++++++ .../components/ulw-loop/dist/codex-hook.d.ts | 28 ++ .../components/ulw-loop/dist/codex-hook.js | 145 +++++++++ .../ulw-loop/dist/command-types.d.ts | 34 ++ .../components/ulw-loop/dist/command-types.js | 1 + .../components/ulw-loop/dist/constants.d.ts | 16 + .../omo/components/ulw-loop/dist/constants.js | 41 +++ .../ulw-loop/dist/domain-types.d.ts | 95 ++++++ .../components/ulw-loop/dist/domain-types.js | 1 + .../components/ulw-loop/dist/evidence.d.ts | 31 ++ .../omo/components/ulw-loop/dist/evidence.js | 119 +++++++ .../components/ulw-loop/dist/goal-status.d.ts | 12 + .../components/ulw-loop/dist/goal-status.js | 69 +++++ .../omo/components/ulw-loop/dist/paths.d.ts | 16 + plugins/omo/components/ulw-loop/dist/paths.js | 59 ++++ .../components/ulw-loop/dist/plan-crud.d.ts | 48 +++ .../omo/components/ulw-loop/dist/plan-crud.js | 119 +++++++ .../omo/components/ulw-loop/dist/plan-io.d.ts | 8 + .../omo/components/ulw-loop/dist/plan-io.js | 89 ++++++ .../ulw-loop/dist/quality-gate.d.ts | 6 + .../components/ulw-loop/dist/quality-gate.js | 123 ++++++++ .../ulw-loop/dist/review-blockers.d.ts | 16 + .../ulw-loop/dist/review-blockers.js | 70 +++++ .../omo/components/ulw-loop/dist/runtime.d.ts | 10 + .../omo/components/ulw-loop/dist/runtime.js | 13 + .../ulw-loop/dist/steering-types.d.ts | 63 ++++ .../ulw-loop/dist/steering-types.js | 1 + .../components/ulw-loop/dist/steering.d.ts | 6 + .../omo/components/ulw-loop/dist/steering.js | 292 ++++++++++++++++++ .../omo/components/ulw-loop/dist/types.d.ts | 5 + plugins/omo/components/ulw-loop/dist/types.js | 5 + plugins/omo/test/aggregate-hooks.test.mjs | 17 + 211 files changed, 8435 insertions(+) create mode 100644 plugins/omo/components/comment-checker/dist/apply-patch.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/apply-patch.js create mode 100644 plugins/omo/components/comment-checker/dist/cli.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/cli.js create mode 100644 plugins/omo/components/comment-checker/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/codex-hook.js create mode 100644 plugins/omo/components/comment-checker/dist/core-values.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/core-values.js create mode 100644 plugins/omo/components/comment-checker/dist/core.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/core.js create mode 100644 plugins/omo/components/comment-checker/dist/hook-input.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/hook-input.js create mode 100644 plugins/omo/components/comment-checker/dist/record.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/record.js create mode 100644 plugins/omo/components/comment-checker/dist/request-extractor.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/request-extractor.js create mode 100644 plugins/omo/components/comment-checker/dist/runner.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/runner.js create mode 100644 plugins/omo/components/comment-checker/dist/types.d.ts create mode 100644 plugins/omo/components/comment-checker/dist/types.js create mode 100644 plugins/omo/components/git-bash/dist/cli.d.ts create mode 100644 plugins/omo/components/git-bash/dist/cli.js create mode 100644 plugins/omo/components/git-bash/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/git-bash/dist/codex-hook.js create mode 100644 plugins/omo/components/git-bash/dist/index.d.ts create mode 100644 plugins/omo/components/git-bash/dist/index.js create mode 100644 plugins/omo/components/lsp/dist/cli.d.ts create mode 100644 plugins/omo/components/lsp/dist/cli.js create mode 100644 plugins/omo/components/lsp/dist/codex-hook-cli.d.ts create mode 100644 plugins/omo/components/lsp/dist/codex-hook-cli.js create mode 100644 plugins/omo/components/lsp/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/lsp/dist/codex-hook.js create mode 100644 plugins/omo/components/lsp/dist/lsp-session-state.d.ts create mode 100644 plugins/omo/components/lsp/dist/lsp-session-state.js create mode 100644 plugins/omo/components/lsp/dist/mutated-file-paths.d.ts create mode 100644 plugins/omo/components/lsp/dist/mutated-file-paths.js create mode 100644 plugins/omo/components/rules/dist/cli.d.ts create mode 100644 plugins/omo/components/rules/dist/cli.js create mode 100644 plugins/omo/components/rules/dist/codex-hook-options.d.ts create mode 100644 plugins/omo/components/rules/dist/codex-hook-options.js create mode 100644 plugins/omo/components/rules/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/rules/dist/codex-hook.js create mode 100644 plugins/omo/components/rules/dist/config.d.ts create mode 100644 plugins/omo/components/rules/dist/config.js create mode 100644 plugins/omo/components/rules/dist/context-pressure.d.ts create mode 100644 plugins/omo/components/rules/dist/context-pressure.js create mode 100644 plugins/omo/components/rules/dist/debug-log.d.ts create mode 100644 plugins/omo/components/rules/dist/debug-log.js create mode 100644 plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts create mode 100644 plugins/omo/components/rules/dist/dynamic-target-fingerprints.js create mode 100644 plugins/omo/components/rules/dist/hook-output.d.ts create mode 100644 plugins/omo/components/rules/dist/hook-output.js create mode 100644 plugins/omo/components/rules/dist/path-utils.d.ts create mode 100644 plugins/omo/components/rules/dist/path-utils.js create mode 100644 plugins/omo/components/rules/dist/persistent-cache.d.ts create mode 100644 plugins/omo/components/rules/dist/persistent-cache.js create mode 100644 plugins/omo/components/rules/dist/post-compact-budget.d.ts create mode 100644 plugins/omo/components/rules/dist/post-compact-budget.js create mode 100644 plugins/omo/components/rules/dist/post-compact-claim.d.ts create mode 100644 plugins/omo/components/rules/dist/post-compact-claim.js create mode 100644 plugins/omo/components/rules/dist/post-compact-state.d.ts create mode 100644 plugins/omo/components/rules/dist/post-compact-state.js create mode 100644 plugins/omo/components/rules/dist/rules-engine-factory.d.ts create mode 100644 plugins/omo/components/rules/dist/rules-engine-factory.js create mode 100644 plugins/omo/components/rules/dist/rules/cache.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/cache.js create mode 100644 plugins/omo/components/rules/dist/rules/constants.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/constants.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-loader.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-loader.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-paths.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-paths.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-static-loader.js create mode 100644 plugins/omo/components/rules/dist/rules/engine-types.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine-types.js create mode 100644 plugins/omo/components/rules/dist/rules/engine.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/engine.js create mode 100644 plugins/omo/components/rules/dist/rules/errors.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/errors.js create mode 100644 plugins/omo/components/rules/dist/rules/finder-cache.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/finder-cache.js create mode 100644 plugins/omo/components/rules/dist/rules/finder-paths.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/finder-paths.js create mode 100644 plugins/omo/components/rules/dist/rules/finder-sources.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/finder-sources.js create mode 100644 plugins/omo/components/rules/dist/rules/finder.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/finder.js create mode 100644 plugins/omo/components/rules/dist/rules/formatter.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/formatter.js create mode 100644 plugins/omo/components/rules/dist/rules/matcher.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/matcher.js create mode 100644 plugins/omo/components/rules/dist/rules/ordering.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/ordering.js create mode 100644 plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/parser-frontmatter.js create mode 100644 plugins/omo/components/rules/dist/rules/parser-yaml.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/parser-yaml.js create mode 100644 plugins/omo/components/rules/dist/rules/parser.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/parser.js create mode 100644 plugins/omo/components/rules/dist/rules/plugin-root.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/plugin-root.js create mode 100644 plugins/omo/components/rules/dist/rules/project-root.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/project-root.js create mode 100644 plugins/omo/components/rules/dist/rules/scanner.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/scanner.js create mode 100644 plugins/omo/components/rules/dist/rules/sources.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/sources.js create mode 100644 plugins/omo/components/rules/dist/rules/truncator.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/truncator.js create mode 100644 plugins/omo/components/rules/dist/rules/types.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/types.js create mode 100644 plugins/omo/components/rules/dist/session-state-lock.d.ts create mode 100644 plugins/omo/components/rules/dist/session-state-lock.js create mode 100644 plugins/omo/components/rules/dist/sparkshell-awareness.d.ts create mode 100644 plugins/omo/components/rules/dist/sparkshell-awareness.js create mode 100644 plugins/omo/components/rules/dist/static-injection.d.ts create mode 100644 plugins/omo/components/rules/dist/static-injection.js create mode 100644 plugins/omo/components/rules/dist/tool-paths.d.ts create mode 100644 plugins/omo/components/rules/dist/tool-paths.js create mode 100644 plugins/omo/components/rules/dist/transcript-rule-filter.d.ts create mode 100644 plugins/omo/components/rules/dist/transcript-rule-filter.js create mode 100644 plugins/omo/components/rules/dist/transcript-search.d.ts create mode 100644 plugins/omo/components/rules/dist/transcript-search.js create mode 100644 plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/boulder-reader.js create mode 100644 plugins/omo/components/start-work-continuation/dist/cli.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/cli.js create mode 100644 plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/codex-hook.js create mode 100644 plugins/omo/components/start-work-continuation/dist/directive.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/directive.js create mode 100644 plugins/omo/components/start-work-continuation/dist/index.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/index.js create mode 100644 plugins/omo/components/start-work-continuation/dist/types.d.ts create mode 100644 plugins/omo/components/start-work-continuation/dist/types.js create mode 100644 plugins/omo/components/telemetry/dist/atomic-write.d.ts create mode 100644 plugins/omo/components/telemetry/dist/atomic-write.js create mode 100644 plugins/omo/components/telemetry/dist/cli.d.ts create mode 100644 plugins/omo/components/telemetry/dist/cli.js create mode 100644 plugins/omo/components/telemetry/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/telemetry/dist/codex-hook.js create mode 100644 plugins/omo/components/telemetry/dist/data-path.d.ts create mode 100644 plugins/omo/components/telemetry/dist/data-path.js create mode 100644 plugins/omo/components/telemetry/dist/diagnostics.d.ts create mode 100644 plugins/omo/components/telemetry/dist/diagnostics.js create mode 100644 plugins/omo/components/telemetry/dist/env-flags.d.ts create mode 100644 plugins/omo/components/telemetry/dist/env-flags.js create mode 100644 plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts create mode 100644 plugins/omo/components/telemetry/dist/posthog-activity-state.js create mode 100644 plugins/omo/components/telemetry/dist/posthog.d.ts create mode 100644 plugins/omo/components/telemetry/dist/posthog.js create mode 100644 plugins/omo/components/telemetry/dist/product-identity.d.ts create mode 100644 plugins/omo/components/telemetry/dist/product-identity.js create mode 100644 plugins/omo/components/ultrawork/dist/cli.d.ts create mode 100644 plugins/omo/components/ultrawork/dist/cli.js create mode 100644 plugins/omo/components/ultrawork/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/ultrawork/dist/codex-hook.js create mode 100644 plugins/omo/components/ultrawork/dist/directive.d.ts create mode 100644 plugins/omo/components/ultrawork/dist/directive.js create mode 100644 plugins/omo/components/ulw-loop/dist/checkpoint.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/checkpoint.js create mode 100644 plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/cli-arg-parser.js create mode 100644 plugins/omo/components/ulw-loop/dist/cli-commands.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/cli-commands.js create mode 100644 plugins/omo/components/ulw-loop/dist/cli-output.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/cli-output.js create mode 100644 plugins/omo/components/ulw-loop/dist/cli-steering.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/cli-steering.js create mode 100644 plugins/omo/components/ulw-loop/dist/cli.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/cli.js create mode 100644 plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js create mode 100644 plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js create mode 100644 plugins/omo/components/ulw-loop/dist/codex-hook.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/codex-hook.js create mode 100644 plugins/omo/components/ulw-loop/dist/command-types.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/command-types.js create mode 100644 plugins/omo/components/ulw-loop/dist/constants.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/constants.js create mode 100644 plugins/omo/components/ulw-loop/dist/domain-types.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/domain-types.js create mode 100644 plugins/omo/components/ulw-loop/dist/evidence.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/evidence.js create mode 100644 plugins/omo/components/ulw-loop/dist/goal-status.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/goal-status.js create mode 100644 plugins/omo/components/ulw-loop/dist/paths.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/paths.js create mode 100644 plugins/omo/components/ulw-loop/dist/plan-crud.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/plan-crud.js create mode 100644 plugins/omo/components/ulw-loop/dist/plan-io.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/plan-io.js create mode 100644 plugins/omo/components/ulw-loop/dist/quality-gate.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/quality-gate.js create mode 100644 plugins/omo/components/ulw-loop/dist/review-blockers.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/review-blockers.js create mode 100644 plugins/omo/components/ulw-loop/dist/runtime.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/runtime.js create mode 100644 plugins/omo/components/ulw-loop/dist/steering-types.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/steering-types.js create mode 100644 plugins/omo/components/ulw-loop/dist/steering.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/steering.js create mode 100644 plugins/omo/components/ulw-loop/dist/types.d.ts create mode 100644 plugins/omo/components/ulw-loop/dist/types.js diff --git a/plugins/omo/components/comment-checker/dist/apply-patch.d.ts b/plugins/omo/components/comment-checker/dist/apply-patch.d.ts new file mode 100644 index 0000000..d899af3 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/apply-patch.d.ts @@ -0,0 +1,7 @@ +import type { CommentCheckRequest } from "./types.js"; +export declare function extractApplyPatchRequests(event: { + details?: unknown; + input: Record; + toolName: string; +}): CommentCheckRequest[]; +export declare function parseApplyPatchRequests(patch: string, sourceToolName?: string): CommentCheckRequest[]; diff --git a/plugins/omo/components/comment-checker/dist/apply-patch.js b/plugins/omo/components/comment-checker/dist/apply-patch.js new file mode 100644 index 0000000..8eac82b --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/apply-patch.js @@ -0,0 +1,173 @@ +import { getString, isRecord } from "./record.js"; +export function extractApplyPatchRequests(event) { + const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName); + if (metadataRequests.length > 0) + return metadataRequests; + const patch = getString(event.input, ["input", "patch", "command"]); + if (!patch) + return []; + return parseApplyPatchRequests(patch, event.toolName); +} +export function parseApplyPatchRequests(patch, sourceToolName = "apply_patch") { + const requests = []; + let current; + const flush = () => { + if (!current) + return; + if (current.operation === "add") { + const content = joinPatchLines(current.newLines); + if (content.length > 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath: current.filePath, + toolInput: { + file_path: current.filePath, + content, + }, + }); + } + } + if (current.operation === "update") { + const newString = joinPatchLines(current.newLines); + if (newString.length > 0) { + const filePath = current.movePath ?? current.filePath; + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: joinPatchLines(current.oldLines), + new_string: newString, + }, + }); + } + } + current = undefined; + }; + for (const line of patch.split(/\r?\n/)) { + if (line === "*** Begin Patch" || line === "*** End Patch") + continue; + if (line.startsWith("*** Add File: ")) { + flush(); + current = makeAccumulator("add", line.slice("*** Add File: ".length).trim()); + continue; + } + if (line.startsWith("*** Update File: ")) { + flush(); + current = makeAccumulator("update", line.slice("*** Update File: ".length).trim()); + continue; + } + if (line.startsWith("*** Delete File: ")) { + flush(); + current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim()); + continue; + } + if (line.startsWith("*** Move to: ")) { + if (current?.operation === "update") + current.movePath = line.slice("*** Move to: ".length).trim(); + continue; + } + if (!current) + continue; + if (line.startsWith("@@")) + continue; + if (current.operation === "add") { + if (line.startsWith("+")) + current.newLines.push(line.slice(1)); + continue; + } + if (current.operation === "update") { + if (line.startsWith("+")) + current.newLines.push(line.slice(1)); + if (line.startsWith("-")) + current.oldLines.push(line.slice(1)); + } + } + flush(); + return requests; +} +function extractApplyPatchMetadataRequests(details, sourceToolName) { + const metadataFiles = getApplyPatchMetadataFiles(details); + if (metadataFiles.length === 0) + return []; + const requests = []; + for (const file of metadataFiles) { + if (file.type === "delete") + continue; + const filePath = file.movePath ?? file.filePath; + if (file.before.length === 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content: file.after, + }, + }); + continue; + } + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: file.before, + new_string: file.after, + }, + }); + } + return requests; +} +function getApplyPatchMetadataFiles(details) { + if (!isRecord(details)) + return []; + const direct = readApplyPatchMetadataFiles(details["files"]); + if (direct.length > 0) + return direct; + const resultDetails = details["result"]; + const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : []; + if (result.length > 0) + return result; + const metadataDetails = details["metadata"]; + const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : []; + return metadata; +} +function readApplyPatchMetadataFiles(value) { + if (!Array.isArray(value)) + return []; + const files = []; + for (const item of value) { + if (!isRecord(item)) + continue; + const filePath = getString(item, ["filePath", "file_path", "path"]); + const movePath = getString(item, ["movePath", "move_path"]); + const before = getString(item, ["before", "old", "oldString", "old_string"]); + const after = getString(item, ["after", "new", "newString", "new_string"]); + const type = getString(item, ["type", "operation"]); + if (!filePath || before === undefined || after === undefined) + continue; + files.push({ + filePath, + before, + after, + ...(movePath === undefined ? {} : { movePath }), + ...(type === undefined ? {} : { type }), + }); + } + return files; +} +function makeAccumulator(operation, filePath) { + return { + operation, + filePath, + oldLines: [], + newLines: [], + }; +} +function joinPatchLines(lines) { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} diff --git a/plugins/omo/components/comment-checker/dist/cli.d.ts b/plugins/omo/components/comment-checker/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/comment-checker/dist/cli.js b/plugins/omo/components/comment-checker/dist/cli.js new file mode 100644 index 0000000..38a642a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/cli.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { runCodexHookCli } from "./codex-hook.js"; +const [command, subcommand] = process.argv.slice(2); +if (command === "hook" && subcommand === "post-tool-use") { + await runCodexHookCli(); +} +else { + process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n"); + process.exitCode = 2; +} diff --git a/plugins/omo/components/comment-checker/dist/codex-hook.d.ts b/plugins/omo/components/comment-checker/dist/codex-hook.d.ts new file mode 100644 index 0000000..79ec554 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/codex-hook.d.ts @@ -0,0 +1,22 @@ +import { type CommentCheckRequest } from "./core.js"; +import { type CommentCheckerRunner } from "./runner.js"; +export type CodexPostToolUseInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostToolUse"; + model: string; + permission_mode: string; + tool_name: string; + tool_input: Record; + tool_response: unknown; + tool_use_id: string; +}; +export type CodexHookOptions = { + run?: CommentCheckerRunner; +}; +export declare function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[]; +export declare function runCommentCheckerPostToolUse(input: CodexPostToolUseInput, options?: CodexHookOptions): Promise; +export declare function runCodexHookCli(): Promise; +export declare function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined; diff --git a/plugins/omo/components/comment-checker/dist/codex-hook.js b/plugins/omo/components/comment-checker/dist/codex-hook.js new file mode 100644 index 0000000..04ab3f0 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/codex-hook.js @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { extractCommentCheckRequests, isRecord, toHookInput, } from "./core.js"; +import { runCommentChecker } from "./runner.js"; +const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000; +const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200; +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +]; +export function extractCodexCommentCheckRequests(input) { + return extractCommentCheckRequests(toToolResultLike(input)); +} +export async function runCommentCheckerPostToolUse(input, options = {}) { + const requests = extractCodexCommentCheckRequests(input); + if (requests.length === 0) + return ""; + const runner = options.run ?? runCommentChecker; + const warnings = []; + for (const request of requests) { + const context = { + sessionId: input.session_id, + cwd: input.cwd, + ...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }), + }; + const result = await runner(toHookInput(request, context)); + if (result.status === "missing" || result.status === "pass") + continue; + if (result.status === "error") + continue; + const message = normalizeHookText(result.message); + if (message.length > 0) { + warnings.push({ filePath: request.filePath, message }); + } + } + if (warnings.length === 0) + return ""; + return JSON.stringify({ + decision: "block", + reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)), + }); +} +export async function runCodexHookCli() { + const input = await readStdin(); + if (input.trim().length === 0) + return; + const parsed = parseCodexPostToolUseInput(input); + if (!parsed) + return; + const output = await runCommentCheckerPostToolUse(parsed); + if (output.length > 0) { + processStdout.write(output); + processStdout.write("\n"); + } +} +export function parseCodexPostToolUseInput(input) { + let parsed; + try { + parsed = JSON.parse(input); + } + catch { + return undefined; + } + return isCodexPostToolUseInput(parsed) ? parsed : undefined; +} +function toToolResultLike(input) { + return { + toolName: input.tool_name, + input: normalizeToolInput(input.tool_name, input.tool_input), + content: normalizeToolResponse(input.tool_response), + isError: isErrorResponse(input.tool_response), + details: isRecord(input.tool_response) ? input.tool_response : undefined, + }; +} +function normalizeToolInput(toolName, toolInput) { + if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { + return { + ...toolInput, + input: toolInput["command"], + patch: toolInput["command"], + }; + } + return toolInput; +} +function normalizeToolResponse(toolResponse) { + if (typeof toolResponse === "string") { + return [{ type: "text", text: toolResponse }]; + } + if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") { + return [{ type: "text", text: toolResponse["text"] }]; + } + return []; +} +function isErrorResponse(toolResponse) { + return isRecord(toolResponse) && toolResponse["is_error"] === true; +} +function formatWarnings(warnings) { + return warnings + .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) + .join("\n\n"); +} +function normalizeHookText(value) { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function hookFeedbackLimit(transcriptPath) { + return isContextPressureTranscript(transcriptPath) + ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS + : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; +} +function isContextPressureTranscript(transcriptPath) { + if (transcriptPath === null) + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +function limitHookText(text, maxChars) { + if (text.length <= maxChars) + return text; + const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`; + if (marker.length >= maxChars) + return marker.slice(0, maxChars); + const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, ""); + return `${head}${marker}`; +} +function isCodexPostToolUseInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + (typeof value["transcript_path"] === "string" || value["transcript_path"] === null) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + isRecord(value["tool_input"]) && + typeof value["tool_use_id"] === "string"); +} +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf-8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/comment-checker/dist/core-values.d.ts b/plugins/omo/components/comment-checker/dist/core-values.d.ts new file mode 100644 index 0000000..b7f0867 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core-values.d.ts @@ -0,0 +1 @@ +export { getString, isRecord } from "./record.js"; diff --git a/plugins/omo/components/comment-checker/dist/core-values.js b/plugins/omo/components/comment-checker/dist/core-values.js new file mode 100644 index 0000000..b7f0867 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core-values.js @@ -0,0 +1 @@ +export { getString, isRecord } from "./record.js"; diff --git a/plugins/omo/components/comment-checker/dist/core.d.ts b/plugins/omo/components/comment-checker/dist/core.d.ts new file mode 100644 index 0000000..eb4fedd --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core.d.ts @@ -0,0 +1,5 @@ +export { parseApplyPatchRequests } from "./apply-patch.js"; +export { toHookInput } from "./hook-input.js"; +export { isRecord } from "./record.js"; +export { extractCommentCheckRequests, isToolFailureOutput } from "./request-extractor.js"; +export type { CheckerEdit, CheckerToolInput, CheckerToolName, CommentCheckerHookInput, CommentCheckRequest, ImageContent, TextContent, ToolResultContent, ToolResultLike, } from "./types.js"; diff --git a/plugins/omo/components/comment-checker/dist/core.js b/plugins/omo/components/comment-checker/dist/core.js new file mode 100644 index 0000000..2ecc05f --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core.js @@ -0,0 +1,4 @@ +export { parseApplyPatchRequests } from "./apply-patch.js"; +export { toHookInput } from "./hook-input.js"; +export { isRecord } from "./record.js"; +export { extractCommentCheckRequests, isToolFailureOutput } from "./request-extractor.js"; diff --git a/plugins/omo/components/comment-checker/dist/hook-input.d.ts b/plugins/omo/components/comment-checker/dist/hook-input.d.ts new file mode 100644 index 0000000..5093bf0 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/hook-input.d.ts @@ -0,0 +1,6 @@ +import type { CommentCheckerHookInput, CommentCheckRequest } from "./types.js"; +export declare function toHookInput(request: CommentCheckRequest, context: { + readonly sessionId: string; + readonly cwd: string; + readonly transcriptPath?: string; +}): CommentCheckerHookInput; diff --git a/plugins/omo/components/comment-checker/dist/hook-input.js b/plugins/omo/components/comment-checker/dist/hook-input.js new file mode 100644 index 0000000..e12802a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/hook-input.js @@ -0,0 +1,10 @@ +export function toHookInput(request, context) { + return { + session_id: context.sessionId, + tool_name: request.toolName, + transcript_path: context.transcriptPath ?? "", + cwd: context.cwd, + hook_event_name: "PostToolUse", + tool_input: request.toolInput, + }; +} diff --git a/plugins/omo/components/comment-checker/dist/record.d.ts b/plugins/omo/components/comment-checker/dist/record.d.ts new file mode 100644 index 0000000..581ffb6 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/record.d.ts @@ -0,0 +1,2 @@ +export declare function getString(input: Record, keys: readonly string[]): string | undefined; +export declare function isRecord(value: unknown): value is Record; diff --git a/plugins/omo/components/comment-checker/dist/record.js b/plugins/omo/components/comment-checker/dist/record.js new file mode 100644 index 0000000..259e383 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/record.js @@ -0,0 +1,11 @@ +export function getString(input, keys) { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") + return value; + } + return undefined; +} +export function isRecord(value) { + return typeof value === "object" && value !== null; +} diff --git a/plugins/omo/components/comment-checker/dist/request-extractor.d.ts b/plugins/omo/components/comment-checker/dist/request-extractor.d.ts new file mode 100644 index 0000000..ece1a33 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/request-extractor.d.ts @@ -0,0 +1,3 @@ +import type { CommentCheckRequest, ToolResultLike } from "./types.js"; +export declare function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[]; +export declare function isToolFailureOutput(text: string): boolean; diff --git a/plugins/omo/components/comment-checker/dist/request-extractor.js b/plugins/omo/components/comment-checker/dist/request-extractor.js new file mode 100644 index 0000000..2c20f6a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/request-extractor.js @@ -0,0 +1,104 @@ +import { extractApplyPatchRequests } from "./apply-patch.js"; +import { getString, isRecord } from "./record.js"; +export function extractCommentCheckRequests(event) { + if (event.isError) + return []; + if (isToolFailureOutput(getContentText(event.content))) + return []; + const toolName = event.toolName.toLowerCase(); + if (toolName === "write") + return extractWriteRequest(event); + if (toolName === "edit") + return extractEditRequest(event); + if (toolName === "multiedit" || toolName === "multi_edit") + return extractMultiEditRequest(event); + if (toolName === "apply_patch") + return extractApplyPatchRequests(event); + return []; +} +export function isToolFailureOutput(text) { + const lower = text.trim().toLowerCase(); + return (lower.startsWith("error") || + lower.includes("error:") || + lower.includes("failed to") || + lower.includes("could not")); +} +function extractWriteRequest(event) { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const content = getString(event.input, ["content"]); + if (!filePath || content === undefined) + return []; + return [ + { + sourceToolName: event.toolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content, + }, + }, + ]; +} +function extractEditRequest(event) { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const oldString = getString(event.input, ["oldString", "old_string"]); + const newString = getString(event.input, ["newString", "new_string"]); + if (!filePath || oldString === undefined || newString === undefined) + return []; + return [ + { + sourceToolName: event.toolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: oldString, + new_string: newString, + }, + }, + ]; +} +function extractMultiEditRequest(event) { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const edits = getEdits(event.input["edits"]); + if (!filePath || edits.length === 0) + return []; + return [ + { + sourceToolName: event.toolName, + toolName: "MultiEdit", + filePath, + toolInput: { + file_path: filePath, + edits, + }, + }, + ]; +} +function getEdits(value) { + if (!Array.isArray(value)) + return []; + const edits = []; + for (const item of value) { + if (!isRecord(item)) + continue; + const oldString = getString(item, ["oldString", "old_string"]); + const newString = getString(item, ["newString", "new_string"]); + if (oldString === undefined || newString === undefined) + continue; + edits.push({ + old_string: oldString, + new_string: newString, + }); + } + return edits; +} +function getContentText(content) { + if (!content) + return ""; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"); +} diff --git a/plugins/omo/components/comment-checker/dist/runner.d.ts b/plugins/omo/components/comment-checker/dist/runner.d.ts new file mode 100644 index 0000000..285c5ca --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/runner.d.ts @@ -0,0 +1,26 @@ +import type { CommentCheckerHookInput } from "./core.js"; +export type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; +export declare const MAX_PROCESS_OUTPUT_BYTES: number; +export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise; +export type RunCommentCheckerOptions = { + binaryPath?: string; + customPrompt?: string; + resolveBinary?: () => string | undefined; + executor?: ProcessExecutor; +}; +export type CommentCheckerRunResult = { + status: "pass" | "warning" | "error" | "missing"; + message: string; + binaryPath?: string; + exitCode?: number | null; + stdout?: string; + stderr?: string; +}; +export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise; +export declare function runCommentChecker(input: CommentCheckerHookInput, options?: RunCommentCheckerOptions): Promise; +export declare function resolveCommentCheckerBinary(): string | undefined; +export declare function spawnProcess(command: string, args: string[], stdin: string, maxOutputBytes?: number): Promise; diff --git a/plugins/omo/components/comment-checker/dist/runner.js b/plugins/omo/components/comment-checker/dist/runner.js new file mode 100644 index 0000000..bc5f354 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/runner.js @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024; +export async function runCommentChecker(input, options = {}) { + const binaryPath = options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary()); + if (!binaryPath) { + return { + status: "missing", + message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.", + }; + } + const args = ["check"]; + if (options.customPrompt) { + args.push("--prompt", options.customPrompt); + } + const executor = options.executor ?? spawnProcess; + const result = await executor(binaryPath, args, JSON.stringify(input)); + const message = result.stderr || result.stdout; + if (result.exitCode === 0) { + return { + status: "pass", + message: "", + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + if (result.exitCode === 2) { + return { + status: "warning", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + return { + status: "error", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; +} +export function resolveCommentCheckerBinary() { + const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker"; + const fromPackageApi = resolvePackageApiBinary(); + if (fromPackageApi) + return fromPackageApi; + const fromPackage = resolvePackageBinary(binaryName); + if (fromPackage) + return fromPackage; + return undefined; +} +function resolvePackageApiBinary() { + try { + const require = createRequire(import.meta.url); + const packageExports = require("@code-yeongyu/comment-checker"); + if (!isCommentCheckerPackage(packageExports)) + return undefined; + const binaryPath = packageExports.getBinaryPath(); + return existsSync(binaryPath) ? binaryPath : undefined; + } + catch { + return undefined; + } +} +function resolvePackageBinary(binaryName) { + try { + const require = createRequire(import.meta.url); + const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json"); + const binaryPath = join(dirname(packagePath), "bin", binaryName); + return existsSync(binaryPath) ? binaryPath : undefined; + } + catch { + return undefined; + } +} +function isCommentCheckerPackage(value) { + return isRecord(value) && typeof value["getBinaryPath"] === "function"; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +function appendOutput(output, chunk, maxOutputBytes) { + if (output.truncated) + return; + const remainingBytes = maxOutputBytes - output.bytes; + const chunkBytes = Buffer.byteLength(chunk, "utf8"); + if (chunkBytes <= remainingBytes) { + output.text += chunk; + output.bytes += chunkBytes; + return; + } + if (remainingBytes > 0) { + output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8"); + output.bytes += remainingBytes; + } + output.truncated = true; +} +function formatOutput(output, streamName, maxOutputBytes) { + if (!output.truncated) + return output.text; + return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`; +} +export function spawnProcess(command, args, stdin, maxOutputBytes = MAX_PROCESS_OUTPUT_BYTES) { + return new Promise((resolve) => { + const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0; + const proc = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = { text: "", bytes: 0, truncated: false }; + const stderr = { text: "", bytes: 0, truncated: false }; + proc.stdout.setEncoding("utf-8"); + proc.stderr.setEncoding("utf-8"); + proc.stdout.on("data", (chunk) => { + appendOutput(stdout, chunk, outputByteLimit); + }); + proc.stderr.on("data", (chunk) => { + appendOutput(stderr, chunk, outputByteLimit); + }); + proc.once("error", (error) => { + appendOutput(stderr, error.message, outputByteLimit); + resolve({ + exitCode: null, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.once("close", (exitCode) => { + resolve({ + exitCode, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.stdin.end(stdin); + }); +} diff --git a/plugins/omo/components/comment-checker/dist/types.d.ts b/plugins/omo/components/comment-checker/dist/types.d.ts new file mode 100644 index 0000000..95358fd --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/types.d.ts @@ -0,0 +1,43 @@ +export type TextContent = { + type: "text"; + text: string; +}; +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; +export type CheckerToolName = "Write" | "Edit" | "MultiEdit"; +export type CheckerEdit = { + old_string: string; + new_string: string; +}; +export type CheckerToolInput = { + file_path: string; + content?: string; + old_string?: string; + new_string?: string; + edits?: CheckerEdit[]; +}; +export type CommentCheckRequest = { + sourceToolName: string; + toolName: CheckerToolName; + filePath: string; + toolInput: CheckerToolInput; +}; +export type CommentCheckerHookInput = { + session_id: string; + tool_name: CheckerToolName; + transcript_path: string; + cwd: string; + hook_event_name: "PostToolUse"; + tool_input: CheckerToolInput; +}; +export type ToolResultContent = TextContent | ImageContent; +export type ToolResultLike = { + toolName: string; + input: Record; + content?: ToolResultContent[]; + isError?: boolean; + details?: unknown; +}; diff --git a/plugins/omo/components/comment-checker/dist/types.js b/plugins/omo/components/comment-checker/dist/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/git-bash/dist/cli.d.ts b/plugins/omo/components/git-bash/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/git-bash/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/git-bash/dist/cli.js b/plugins/omo/components/git-bash/dist/cli.js new file mode 100644 index 0000000..d59f08f --- /dev/null +++ b/plugins/omo/components/git-bash/dist/cli.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { runGitBashHookCli } from "./codex-hook.js"; +const TOP_LEVEL_HELP = "Usage:\n omo-git-bash-hook hook pre-tool-use\n omo-git-bash-hook hook post-compact\n omo-git-bash-hook help | --help | -h\n"; +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "hook" && argv[1] === "pre-tool-use") { + await runGitBashHookCli(process.stdin, process.stdout, "pre-tool-use"); + return 0; + } + if (command === "hook" && argv[1] === "post-compact") { + await runGitBashHookCli(process.stdin, process.stdout, "post-compact"); + return 0; + } + process.stderr.write(`[omo-git-bash-hook] unknown command: ${argv.join(" ")}\n${TOP_LEVEL_HELP}`); + return 1; +} +main() + .then((code) => { + process.exit(code); +}) + .catch((error) => { + process.stderr.write(`[omo-git-bash-hook] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/plugins/omo/components/git-bash/dist/codex-hook.d.ts b/plugins/omo/components/git-bash/dist/codex-hook.d.ts new file mode 100644 index 0000000..e05e11f --- /dev/null +++ b/plugins/omo/components/git-bash/dist/codex-hook.d.ts @@ -0,0 +1,28 @@ +export interface PreToolUsePayload { + readonly cwd: string; + readonly hook_event_name: "PreToolUse"; + readonly model: string; + readonly permission_mode: string; + readonly session_id: string; + readonly tool_input: unknown; + readonly tool_name: string; + readonly tool_use_id: string; + readonly transcript_path: string | null; + readonly turn_id: string; +} +export interface GitBashHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform | string; + readonly pluginDataRoot?: string; +} +export interface PostCompactPayload { + readonly hook_event_name: "PostCompact"; + readonly session_id: string; + readonly transcript_path?: string | null; + readonly trigger?: string; +} +export declare function parsePreToolUsePayload(raw: string): PreToolUsePayload | null; +export declare function parsePostCompactPayload(raw: string): PostCompactPayload | null; +export declare function applyGitBashPreToolUseReminder(payload: PreToolUsePayload, options?: GitBashHookOptions): string; +export declare function applyGitBashPostCompactReset(payload: PostCompactPayload, options?: GitBashHookOptions): string; +export declare function runGitBashHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, eventName?: "pre-tool-use" | "post-compact", options?: GitBashHookOptions): Promise; diff --git a/plugins/omo/components/git-bash/dist/codex-hook.js b/plugins/omo/components/git-bash/dist/codex-hook.js new file mode 100644 index 0000000..fc1c75e --- /dev/null +++ b/plugins/omo/components/git-bash/dist/codex-hook.js @@ -0,0 +1,137 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +const BASH_TOOL_NAME = "Bash"; +const REMINDER = "On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations."; +export function parsePreToolUsePayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPreToolUsePayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function parsePostCompactPayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPostCompactPayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function applyGitBashPreToolUseReminder(payload, options = {}) { + if (payload.hook_event_name !== "PreToolUse") + return ""; + if (payload.tool_name !== BASH_TOOL_NAME) + return ""; + if (!isWindowsHost(options)) + return ""; + const markerPath = reminderMarkerPath(payload.session_id, options.pluginDataRoot); + if (hasReminderMarker(markerPath)) + return ""; + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, `${new Date().toISOString()}\n`); + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: REMINDER, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export function applyGitBashPostCompactReset(payload, options = {}) { + if (payload.hook_event_name !== "PostCompact") + return ""; + rmSync(reminderMarkerPath(payload.session_id, options.pluginDataRoot), { force: true }); + return ""; +} +export async function runGitBashHookCli(stdin, stdout, eventName = "pre-tool-use", options = {}) { + try { + const raw = await readAll(stdin); + const output = eventName === "post-compact" ? postCompactOutput(raw, options) : preToolUseOutput(raw, options); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +function preToolUseOutput(raw, options) { + const payload = parsePreToolUsePayload(raw); + if (payload === null) + return ""; + return applyGitBashPreToolUseReminder(payload, options); +} +function postCompactOutput(raw, options) { + const payload = parsePostCompactPayload(raw); + if (payload === null) + return ""; + return applyGitBashPostCompactReset(payload, options); +} +function isWindowsHost(options) { + const platform = options.platform ?? process.platform; + if (platform === "win32") + return true; + const env = options.env ?? process.env; + return env["OS"] === "Windows_NT" || env["ComSpec"] !== undefined || env["SystemRoot"] !== undefined; +} +function hasReminderMarker(path) { + return existsSync(path); +} +function reminderMarkerPath(sessionId, pluginDataRoot) { + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "omo-git-bash"); + return join(root, "git-bash-reminder", `${safePathSegment(sessionId)}.seen`); +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} +function isPreToolUsePayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || typeof value["transcript_path"] === "string") && + typeof value["turn_id"] === "string" && + Object.hasOwn(value, "tool_input")); +} +function isPostCompactPayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + (value["transcript_path"] === undefined || + value["transcript_path"] === null || + typeof value["transcript_path"] === "string") && + (value["trigger"] === undefined || typeof value["trigger"] === "string")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readAll(stdin) { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/git-bash/dist/index.d.ts b/plugins/omo/components/git-bash/dist/index.d.ts new file mode 100644 index 0000000..45220c8 --- /dev/null +++ b/plugins/omo/components/git-bash/dist/index.d.ts @@ -0,0 +1 @@ +export { applyGitBashPostCompactReset, applyGitBashPreToolUseReminder, parsePostCompactPayload, parsePreToolUsePayload, runGitBashHookCli, type GitBashHookOptions, type PostCompactPayload, type PreToolUsePayload, } from "./codex-hook.js"; diff --git a/plugins/omo/components/git-bash/dist/index.js b/plugins/omo/components/git-bash/dist/index.js new file mode 100644 index 0000000..b28782a --- /dev/null +++ b/plugins/omo/components/git-bash/dist/index.js @@ -0,0 +1 @@ +export { applyGitBashPostCompactReset, applyGitBashPreToolUseReminder, parsePostCompactPayload, parsePreToolUsePayload, runGitBashHookCli, } from "./codex-hook.js"; diff --git a/plugins/omo/components/lsp/dist/cli.d.ts b/plugins/omo/components/lsp/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/lsp/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/lsp/dist/cli.js b/plugins/omo/components/lsp/dist/cli.js new file mode 100644 index 0000000..6938721 --- /dev/null +++ b/plugins/omo/components/lsp/dist/cli.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { argv, execPath, stderr } from "node:process"; +import { runPostCompactHookCli, runPostToolUseHookCli } from "./codex-hook-cli.js"; +const require = createRequire(import.meta.url); +const PACKAGE_LSP_MCP_CLI = "@code-yeongyu/lsp-tools-mcp/dist/cli.js"; +async function main() { + const [command = "mcp", subcommand = ""] = argv.slice(2); + if (command === "hook" && subcommand === "post-tool-use") { + await runPostToolUseHookCli(); + return; + } + if (command === "hook" && subcommand === "post-compact") { + await runPostCompactHookCli(); + return; + } + if (command === "mcp") { + await runPackageLspMcpCli(); + return; + } + stderr.write("Usage: omo-lsp [mcp | hook post-tool-use | hook post-compact]\n"); + process.exitCode = 2; +} +main().catch((error) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + process.exitCode = 1; +}); +async function runPackageLspMcpCli() { + const cliPath = require.resolve(PACKAGE_LSP_MCP_CLI); + const child = spawn(execPath, [cliPath, "mcp"], { stdio: "inherit" }); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code !== null && code !== 0) + process.exitCode = code; + if (code === null && signal !== null) + process.exitCode = 1; + resolve(); + }); + }); +} diff --git a/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts b/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts new file mode 100644 index 0000000..d7d9876 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts @@ -0,0 +1,2 @@ +export declare function runPostToolUseHookCli(stdin?: NodeJS.ReadStream): Promise; +export declare function runPostCompactHookCli(stdin?: NodeJS.ReadStream): Promise; diff --git a/plugins/omo/components/lsp/dist/codex-hook-cli.js b/plugins/omo/components/lsp/dist/codex-hook-cli.js new file mode 100644 index 0000000..c361842 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook-cli.js @@ -0,0 +1,40 @@ +import { stdin as processStdin } from "node:process"; +import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"; +import { isRecord, runLspPostCompactHook, runLspPostToolUseHook } from "./codex-hook.js"; +export async function runPostToolUseHookCli(stdin = processStdin) { + await runHookCli((input) => runLspPostToolUseHook(input), stdin); +} +export async function runPostCompactHookCli(stdin = processStdin) { + await runHookCli((input) => runLspPostCompactHook(input), stdin); +} +async function runHookCli(runHook, stdin) { + try { + const raw = await readStdin(stdin); + if (!raw.trim()) + return; + let parsed; + try { + parsed = JSON.parse(raw); + } + catch (error) { + if (error instanceof SyntaxError) + return; + throw error; + } + const input = isRecord(parsed) ? parsed : {}; + const output = await runHook(input); + if (output) + process.stdout.write(output); + } + finally { + await disposeDefaultLspManager(); + } +} +async function readStdin(stdin) { + stdin.setEncoding("utf8"); + let raw = ""; + for await (const chunk of stdin) { + raw += chunk; + } + return raw; +} diff --git a/plugins/omo/components/lsp/dist/codex-hook.d.ts b/plugins/omo/components/lsp/dist/codex-hook.d.ts new file mode 100644 index 0000000..5da31a2 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook.d.ts @@ -0,0 +1,16 @@ +export { extractMutatedFilePaths } from "./mutated-file-paths.js"; +export type DiagnosticsRunner = (filePath: string) => Promise; +export interface CodexPostToolUseInput { + session_id?: unknown; + tool_name?: unknown; + tool_input?: unknown; + tool_response?: unknown; + transcript_path?: unknown; +} +export interface CodexPostCompactInput { + session_id?: unknown; +} +export declare function runLspDiagnosticsText(filePath: string): Promise; +export declare function runLspPostToolUseHook(input: CodexPostToolUseInput, runDiagnostics?: DiagnosticsRunner): Promise; +export declare function runLspPostCompactHook(input: CodexPostCompactInput): Promise; +export declare function isRecord(value: unknown): value is Record; diff --git a/plugins/omo/components/lsp/dist/codex-hook.js b/plugins/omo/components/lsp/dist/codex-hook.js new file mode 100644 index 0000000..116aa23 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook.js @@ -0,0 +1,176 @@ +import { readFileSync } from "node:fs"; +import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js"; +import { isUnavailableLspDiagnostics, markLspSessionCompacted, recordLspDiagnosticsObservations, sessionIdFrom, shouldSkipUnavailableLspDiagnostics, } from "./lsp-session-state.js"; +import { extractMutatedFilePaths } from "./mutated-file-paths.js"; +export { extractMutatedFilePaths } from "./mutated-file-paths.js"; +const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found"; +const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:"; +const DIAGNOSTIC_START_PATTERN = /(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/g; +const DIAGNOSTIC_CHUNK_PATTERN = /^(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/; +const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000; +const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200; +const MAX_CONCURRENT_DIAGNOSTICS = 4; +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +]; +export async function runLspDiagnosticsText(filePath) { + const result = await executeLspDiagnostics({ filePath, severity: "error" }); + return result.content.map((block) => block.text).join("\n"); +} +export async function runLspPostToolUseHook(input, runDiagnostics = runLspDiagnosticsText) { + const sessionId = sessionIdFrom(input); + const filePaths = extractMutatedFilePaths(input).filter((filePath) => !shouldSkipUnavailableLspDiagnostics(filePath, sessionId)); + if (filePaths.length === 0) + return ""; + const blocks = []; + const observations = []; + for (const { filePath, diagnostics } of await collectDiagnostics(filePaths, runDiagnostics)) { + const unavailable = isUnavailableLspDiagnostics(diagnostics); + observations.push({ filePath, unavailable }); + if (isCleanDiagnostics(diagnostics)) + continue; + if (unavailable) + continue; + blocks.push({ filePath, diagnostics }); + } + recordLspDiagnosticsObservations(sessionId, observations); + if (blocks.length === 0) + return ""; + const rawReason = blocks.map(formatDiagnosticBlock).join("\n\n"); + const reason = limitHookText(rawReason, hookFeedbackLimit(input.transcript_path)); + const output = { + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: reason, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export async function runLspPostCompactHook(input) { + markLspSessionCompacted(sessionIdFrom(input)); + return ""; +} +async function collectDiagnostics(filePaths, runDiagnostics) { + const results = []; + let nextIndex = 0; + const workerCount = Math.min(MAX_CONCURRENT_DIAGNOSTICS, filePaths.length); + async function worker() { + for (;;) { + const index = nextIndex; + nextIndex += 1; + const filePath = filePaths[index]; + if (filePath === undefined) + return; + results[index] = { filePath, diagnostics: await collectFileDiagnostics(filePath, runDiagnostics) }; + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} +async function collectFileDiagnostics(filePath, runDiagnostics) { + try { + return (await runDiagnostics(filePath)).trim(); + } + catch (error) { + return formatDiagnosticsError(error); + } +} +function formatDiagnosticsError(error) { + if (error instanceof Error) { + const message = error.message.trim(); + if (message.length > 0) + return message; + } + return String(error).trim(); +} +function formatDiagnosticBlock({ filePath, diagnostics }) { + return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`; +} +function formatDiagnosticsForDisplay(diagnostics) { + const chunks = splitDiagnosticChunks(diagnostics); + if (!chunks.some(isDiagnosticChunk)) + return chunks.join("\n").trim(); + return chunks.map(formatDiagnosticChunk).join("\n"); +} +function splitDiagnosticChunks(diagnostics) { + const normalized = diagnostics.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); + if (normalized.length === 0) + return []; + const matches = Array.from(normalized.matchAll(DIAGNOSTIC_START_PATTERN)); + const firstMatch = matches[0]; + if (firstMatch?.index === undefined) + return [normalized]; + const chunks = []; + const leadingText = normalized.slice(0, firstMatch.index).trim(); + if (leadingText.length > 0) + chunks.push(leadingText); + for (const [index, match] of matches.entries()) { + if (match.index === undefined) + continue; + const nextMatch = matches[index + 1]; + const end = nextMatch?.index ?? normalized.length; + const chunk = normalized.slice(match.index, end).trim(); + if (chunk.length > 0) + chunks.push(chunk); + } + return chunks; +} +function formatDiagnosticChunk(chunk) { + const lines = chunk.split("\n"); + const firstLine = lines[0]; + if (firstLine === undefined) + return ""; + if (!isDiagnosticChunk(firstLine)) + return chunk; + const followingLines = lines.slice(1).map((line) => ` ${line}`); + return [`- ${firstLine}`, ...followingLines].join("\n"); +} +function isDiagnosticChunk(chunk) { + return DIAGNOSTIC_CHUNK_PATTERN.test(chunk); +} +function hookFeedbackLimit(transcriptPath) { + return isContextPressureTranscript(transcriptPath) + ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS + : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; +} +function isContextPressureTranscript(transcriptPath) { + if (typeof transcriptPath !== "string") + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +function limitHookText(text, maxChars) { + if (text.length <= maxChars) + return text; + const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`; + if (marker.length >= maxChars) + return marker.slice(0, maxChars); + const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, ""); + return `${head}${marker}`; +} +function isCleanDiagnostics(diagnostics) { + return (diagnostics.length === 0 || + diagnostics === CLEAN_DIAGNOSTICS_TEXT || + diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT)); +} +export function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/lsp/dist/lsp-session-state.d.ts b/plugins/omo/components/lsp/dist/lsp-session-state.d.ts new file mode 100644 index 0000000..b77e971 --- /dev/null +++ b/plugins/omo/components/lsp/dist/lsp-session-state.d.ts @@ -0,0 +1,11 @@ +export interface DiagnosticsObservation { + readonly filePath: string; + readonly unavailable: boolean; +} +export declare function sessionIdFrom(input: { + readonly session_id?: unknown; +}): string | undefined; +export declare function shouldSkipUnavailableLspDiagnostics(filePath: string, sessionId: string | undefined): boolean; +export declare function recordLspDiagnosticsObservations(sessionId: string | undefined, observations: readonly DiagnosticsObservation[]): void; +export declare function markLspSessionCompacted(sessionId: string | undefined): void; +export declare function isUnavailableLspDiagnostics(diagnostics: string): boolean; diff --git a/plugins/omo/components/lsp/dist/lsp-session-state.js b/plugins/omo/components/lsp/dist/lsp-session-state.js new file mode 100644 index 0000000..50fc381 --- /dev/null +++ b/plugins/omo/components/lsp/dist/lsp-session-state.js @@ -0,0 +1,92 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, extname, join } from "node:path"; +export function sessionIdFrom(input) { + return typeof input.session_id === "string" && input.session_id.length > 0 ? input.session_id : undefined; +} +export function shouldSkipUnavailableLspDiagnostics(filePath, sessionId) { + if (sessionId === undefined) + return false; + const state = readSessionState(sessionStatePath(sessionId)); + const extension = extensionKey(filePath); + return (extension !== undefined && + state.postCompactProbePending !== true && + state.unavailableExtensions.includes(extension)); +} +export function recordLspDiagnosticsObservations(sessionId, observations) { + if (sessionId === undefined || observations.length === 0) + return; + const state = readSessionState(sessionStatePath(sessionId)); + const unavailableExtensions = new Set(state.unavailableExtensions); + for (const observation of observations) { + const extension = extensionKey(observation.filePath); + if (extension === undefined) + continue; + if (observation.unavailable) { + unavailableExtensions.add(extension); + } + else { + unavailableExtensions.delete(extension); + } + } + writeSessionState(sessionStatePath(sessionId), { unavailableExtensions: [...unavailableExtensions].sort() }); +} +export function markLspSessionCompacted(sessionId) { + if (sessionId === undefined) + return; + const state = readSessionState(sessionStatePath(sessionId)); + if (state.unavailableExtensions.length === 0) + return; + writeSessionState(sessionStatePath(sessionId), { + unavailableExtensions: state.unavailableExtensions, + postCompactProbePending: true, + }); +} +export function isUnavailableLspDiagnostics(diagnostics) { + const normalized = diagnostics.trim(); + return (normalized.includes("LSP request timeout (method: initialize)") || + normalized.includes("LSP server is still initializing") || + normalized.includes("NOT INSTALLED") || + normalized.includes("Command not found:")); +} +function sessionStatePath(sessionId) { + const root = process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-lsp"); + return join(root, "sessions", `${safePathSegment(sessionId)}.json`); +} +function readSessionState(path) { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (isLspSessionState(parsed)) + return parsed; + return emptyState(); + } + catch (error) { + if (error instanceof SyntaxError || (isRecord(error) && error["code"] === "ENOENT")) + return emptyState(); + throw error; + } +} +function writeSessionState(path, state) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(state)}\n`); +} +function emptyState() { + return { unavailableExtensions: [] }; +} +function extensionKey(filePath) { + const extension = extname(filePath).toLowerCase(); + return extension.length === 0 ? undefined : extension; +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; +} +function isLspSessionState(value) { + if (!isRecord(value) || !Array.isArray(value["unavailableExtensions"])) + return false; + const postCompactProbePending = value["postCompactProbePending"]; + return (value["unavailableExtensions"].every((item) => typeof item === "string") && + (postCompactProbePending === undefined || typeof postCompactProbePending === "boolean")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts b/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts new file mode 100644 index 0000000..28e1c6c --- /dev/null +++ b/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts @@ -0,0 +1,6 @@ +export interface MutatedFileInput { + readonly tool_name?: unknown; + readonly tool_input?: unknown; + readonly tool_response?: unknown; +} +export declare function extractMutatedFilePaths(input: MutatedFileInput): string[]; diff --git a/plugins/omo/components/lsp/dist/mutated-file-paths.js b/plugins/omo/components/lsp/dist/mutated-file-paths.js new file mode 100644 index 0000000..4c76c13 --- /dev/null +++ b/plugins/omo/components/lsp/dist/mutated-file-paths.js @@ -0,0 +1,79 @@ +const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]); +export function extractMutatedFilePaths(input) { + if (!isMutationTool(input.tool_name)) + return []; + if (isFailedToolResponse(input.tool_response)) + return []; + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + const paths = new Set(); + addStringValue(paths, toolInput["path"]); + addStringValue(paths, toolInput["filePath"]); + addStringValue(paths, toolInput["file_path"]); + addStringArray(paths, toolInput["paths"]); + addStringArray(paths, toolInput["filePaths"]); + addStringArray(paths, toolInput["file_paths"]); + addPatchPayloads(paths, toolInput); + addPatchFiles(paths, toolInput["files"]); + addPatchFiles(paths, toolInput["changes"]); + return [...paths]; +} +function isMutationTool(value) { + if (typeof value !== "string") + return false; + return MUTATION_TOOL_NAMES.has(value.toLowerCase()); +} +function isFailedToolResponse(value) { + if (!isRecord(value)) + return false; + return (value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"); +} +function addStringValue(paths, value) { + if (typeof value === "string" && value.length > 0) { + paths.add(value); + } +} +function addStringArray(paths, value) { + if (!Array.isArray(value)) + return; + for (const item of value) { + addStringValue(paths, item); + } +} +function addPatchPayloads(paths, input) { + addPatchInput(paths, input["input"]); + addPatchInput(paths, input["patch"]); + addPatchInput(paths, input["command"]); +} +function addPatchInput(paths, value) { + if (typeof value !== "string") + return; + for (const line of value.split("\n")) { + const path = extractPatchHeaderPath(line); + if (path !== undefined) + paths.add(path); + } +} +function extractPatchHeaderPath(line) { + const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "]; + for (const prefix of prefixes) { + if (line.startsWith(prefix)) + return line.slice(prefix.length).trim(); + } + return undefined; +} +function addPatchFiles(paths, value) { + if (!Array.isArray(value)) + return; + for (const item of value) { + if (!isRecord(item)) + continue; + addStringValue(paths, item["path"]); + addStringValue(paths, item["filePath"]); + addStringValue(paths, item["file_path"]); + addStringValue(paths, item["movePath"]); + addStringValue(paths, item["move_path"]); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/rules/dist/cli.d.ts b/plugins/omo/components/rules/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/rules/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/rules/dist/cli.js b/plugins/omo/components/rules/dist/cli.js new file mode 100644 index 0000000..2afd0ab --- /dev/null +++ b/plugins/omo/components/rules/dist/cli.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runPostCompactHook, runPostToolUseHook, runSessionStartHook, runUserPromptSubmitHook, } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "session-start") { + await runHookCli("SessionStart"); +} +else if (command === "hook" && subcommand === "user-prompt-submit") { + await runHookCli("UserPromptSubmit"); +} +else if (command === "hook" && subcommand === "post-tool-use") { + await runHookCli("PostToolUse"); +} +else if (command === "hook" && subcommand === "post-compact") { + await runHookCli("PostCompact"); +} +else { + process.stderr.write("Usage: omo-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n"); + process.exitCode = 1; +} +async function runHookCli(eventName) { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + if (!parsed) + return; + const pluginDataRoot = process.env["PLUGIN_DATA"]; + const options = pluginDataRoot === undefined ? {} : { pluginDataRoot }; + const output = await runHook(eventName, parsed, options); + if (output.length > 0) { + processStdout.write(output); + } +} +async function runHook(eventName, parsed, options) { + switch (eventName) { + case "SessionStart": + return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : ""; + case "UserPromptSubmit": + return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : ""; + case "PostToolUse": + return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : ""; + case "PostCompact": + return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : ""; + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch { + return undefined; + } +} +function isCodexSessionStartInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string"); +} +function isCodexUserPromptSubmitInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["prompt"] === "string"); +} +function isCodexPostToolUseInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string"); +} +function isCodexPostCompactInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + (value["trigger"] === "manual" || value["trigger"] === "auto")); +} +function isStringOrNull(value) { + return typeof value === "string" || value === null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/rules/dist/codex-hook-options.d.ts b/plugins/omo/components/rules/dist/codex-hook-options.d.ts new file mode 100644 index 0000000..96545a0 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook-options.d.ts @@ -0,0 +1,5 @@ +export interface CodexRulesHookOptions { + env?: NodeJS.ProcessEnv; + pluginDataRoot?: string; + platform?: NodeJS.Platform; +} diff --git a/plugins/omo/components/rules/dist/codex-hook-options.js b/plugins/omo/components/rules/dist/codex-hook-options.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook-options.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/rules/dist/codex-hook.d.ts b/plugins/omo/components/rules/dist/codex-hook.d.ts new file mode 100644 index 0000000..74474f9 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook.d.ts @@ -0,0 +1,47 @@ +import type { CodexRulesHookOptions } from "./codex-hook-options.js"; +export type { CodexRulesHookOptions } from "./codex-hook-options.js"; +export type CodexSessionStartInput = { + session_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "SessionStart"; + model: string; + permission_mode: string; + source: "startup" | "resume" | "clear" | "compact"; +}; +export type CodexUserPromptSubmitInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "UserPromptSubmit"; + model: string; + permission_mode: string; + prompt: string; +}; +export type CodexPostToolUseInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostToolUse"; + model: string; + permission_mode: string; + tool_name: string; + tool_input: unknown; + tool_response: unknown; + tool_use_id: string; +}; +export type CodexPostCompactInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostCompact"; + model: string; + trigger: "manual" | "auto"; +}; +export declare function runSessionStartHook(input: CodexSessionStartInput, options?: CodexRulesHookOptions): Promise; +export declare function runPostCompactHook(input: CodexPostCompactInput, options?: CodexRulesHookOptions): Promise; +export declare function runUserPromptSubmitHook(input: CodexUserPromptSubmitInput, options?: CodexRulesHookOptions): Promise; +export declare function runPostToolUseHook(input: CodexPostToolUseInput, options?: CodexRulesHookOptions): Promise; diff --git a/plugins/omo/components/rules/dist/codex-hook.js b/plugins/omo/components/rules/dist/codex-hook.js new file mode 100644 index 0000000..eab9a27 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook.js @@ -0,0 +1,125 @@ +import { configFromEnvironment } from "./config.js"; +import { hasContextPressureMarker, transcriptHasContextPressureMarker } from "./context-pressure.js"; +import { createHookDebugTimer } from "./debug-log.js"; +import { fingerprintDynamicTargets } from "./dynamic-target-fingerprints.js"; +import { formatAdditionalContextOutput } from "./hook-output.js"; +import { displayPath, uniqueStrings } from "./path-utils.js"; +import { claimPostCompactPending, clearSessionState, hasPostCompactPending, hydrateEngineState, isPostCompactRecoveryInProgress, markSessionCompacted, persistEngineState, sessionCachePath, } from "./persistent-cache.js"; +import { withPostCompactBudget } from "./post-compact-budget.js"; +import { claimedPostCompactKind, shouldSkipPostCompactClaim } from "./post-compact-claim.js"; +import { createRulesEngine } from "./rules-engine-factory.js"; +import { runStaticInjection } from "./static-injection.js"; +import { extractCodexToolPaths } from "./tool-paths.js"; +import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js"; +export async function runSessionStartHook(input, options = {}) { + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + if (input.source === "clear") { + clearSessionState(cachePath); + } + else if (input.source !== "resume" && input.source !== "compact" && !hasPostCompactPending(cachePath)) { + clearSessionState(cachePath); + } + const postCompactClaim = input.source === "clear" ? "not-pending" : claimPostCompactPending(cachePath, "static"); + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "static") ?? + (input.source === "compact" && postCompactClaim === "not-pending" ? "static" : undefined); + if (shouldSkipPostCompactClaim(postCompactClaim, input.source === "compact" && isPostCompactRecoveryInProgress(cachePath, "static"))) { + return ""; + } + const transcriptPath = input.source === "clear" ? null : input.transcript_path; + return runStaticInjection(input.cwd, transcriptPath, "SessionStart", cachePath, options, completedPostCompactKind, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }, input.model); +} +export async function runPostCompactHook(input, options = {}) { + markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} +export async function runUserPromptSubmitHook(input, options = {}) { + if (hasContextPressureMarker(input.prompt)) { + return ""; + } + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + const postCompactClaim = claimPostCompactPending(cachePath, "static"); + if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) { + return ""; + } + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "static"); + if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "static"))) { + return ""; + } + return runStaticInjection(input.cwd, input.transcript_path, "UserPromptSubmit", cachePath, options, completedPostCompactKind, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }, input.model); +} +export async function runPostToolUseHook(input, options = {}) { + const debugTimer = createHookDebugTimer("PostToolUse"); + const config = configFromEnvironment(options.env); + debugTimer.lap("config", { disabled: config.disabled, mode: config.mode }); + if (config.disabled || config.mode === "off" || config.mode === "static") { + debugTimer.done({ outputBytes: 0, reason: "disabled" }); + return ""; + } + const targetPaths = extractCodexToolPaths(input, input.cwd); + debugTimer.lap("extract", { + targets: targetPaths.length, + uniqueTargets: uniqueStrings(targetPaths).length, + tool: input.tool_name, + }); + const firstTargetPath = targetPaths[0]; + if (firstTargetPath === undefined) { + debugTimer.done({ outputBytes: 0, reason: "no-target" }); + return ""; + } + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + const postCompactClaim = claimPostCompactPending(cachePath, "dynamic"); + if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) { + debugTimer.done({ outputBytes: 0, reason: "context-pressure-transcript" }); + return ""; + } + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "dynamic"); + if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "dynamic"))) { + debugTimer.done({ outputBytes: 0, reason: "post-compact-recovery-in-progress" }); + return ""; + } + const engine = createRulesEngine(options, completedPostCompactKind !== undefined + ? withPostCompactBudget(config, { model: input.model, transcriptPath: input.transcript_path }) + : config); + hydrateEngineState(engine, cachePath); + debugTimer.lap("hydrate", { + dynamicDedupScopes: engine.state.dynamicDedup.size, + dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size, + staticDedup: engine.state.staticDedup.size, + }); + const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config); + debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length }); + const pendingTargetFingerprints = dynamicTargetFingerprints.filter((target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint); + debugTimer.lap("pending", { pending: pendingTargetFingerprints.length }); + if (pendingTargetFingerprints.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "no-pending" }); + debugTimer.done({ outputBytes: 0, reason: "no-pending" }); + return ""; + } + const loaded = engine.loadDynamicRules(input.cwd, pendingTargetFingerprints.map((target) => target.targetPath)); + debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length }); + const rules = filterRulesAlreadyInTranscript(loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)), input.transcript_path, (rule) => { + engine.markDynamicInjected(rule); + }, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }); + debugTimer.lap("filter", { rules: rules.length }); + for (const target of pendingTargetFingerprints) { + engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint); + } + if (rules.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "no-rules" }); + debugTimer.done({ outputBytes: 0, reason: "no-rules" }); + return ""; + } + const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath; + const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath)); + debugTimer.lap("format", { blockChars: block.length, rules: rules.length }); + for (const rule of rules) { + engine.markDynamicInjected(rule); + } + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "emit" }); + const output = formatAdditionalContextOutput("PostToolUse", block); + debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" }); + return output; +} diff --git a/plugins/omo/components/rules/dist/config.d.ts b/plugins/omo/components/rules/dist/config.d.ts new file mode 100644 index 0000000..ef07d08 --- /dev/null +++ b/plugins/omo/components/rules/dist/config.d.ts @@ -0,0 +1,2 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export declare function configFromEnvironment(env?: NodeJS.ProcessEnv): PiRulesConfig; diff --git a/plugins/omo/components/rules/dist/config.js b/plugins/omo/components/rules/dist/config.js new file mode 100644 index 0000000..9b9c9d4 --- /dev/null +++ b/plugins/omo/components/rules/dist/config.js @@ -0,0 +1,89 @@ +import { SOURCE_PRIORITY } from "./rules/constants.js"; +import { defaultConfig } from "./rules/engine.js"; +export function configFromEnvironment(env = process.env) { + const config = defaultConfig(); + const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")); + config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")); + config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode; + config.maxRuleChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ?? + config.maxRuleChars; + config.maxResultChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ?? + config.maxResultChars; + config.postCompactMaxRuleChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")) ?? config.postCompactMaxRuleChars; + config.postCompactMaxResultChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")) ?? config.postCompactMaxResultChars; + config.enabledSources = parseEnabledSources(firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"), disableBundledRules); + return config; +} +function firstEnv(env, ...names) { + for (const name of names) { + const value = env[name]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} +function isTruthy(value) { + if (value === undefined) + return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} +function parseMode(value) { + if (value === undefined) + return undefined; + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case "static": + case "dynamic": + case "both": + case "off": + return normalized; + default: + return undefined; + } +} +function parsePositiveInteger(value) { + if (value === undefined) + return undefined; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} +function parseEnabledSources(value, disableBundledRules) { + if (value === undefined || value.trim().toLowerCase() === "auto") { + return disableBundledRules ? sourcesWithoutBundledRules() : "auto"; + } + const sources = []; + for (const rawSource of value.split(",")) { + const source = toRuleSource(rawSource.trim()); + if (source === null) { + continue; + } + sources.push(source); + } + const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources; + return enabledSources; +} +function sourcesWithoutBundledRules() { + return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled"); +} +function toRuleSource(value) { + switch (value) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + case ".github/copilot-instructions.md": + case "CONTEXT.md": + case "plugin-bundled": + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + return value; + default: + return null; + } +} diff --git a/plugins/omo/components/rules/dist/context-pressure.d.ts b/plugins/omo/components/rules/dist/context-pressure.d.ts new file mode 100644 index 0000000..ca60911 --- /dev/null +++ b/plugins/omo/components/rules/dist/context-pressure.d.ts @@ -0,0 +1,2 @@ +export declare function hasContextPressureMarker(text: string): boolean; +export declare function transcriptHasContextPressureMarker(transcriptPath: string | null | undefined): boolean; diff --git a/plugins/omo/components/rules/dist/context-pressure.js b/plugins/omo/components/rules/dist/context-pressure.js new file mode 100644 index 0000000..0102909 --- /dev/null +++ b/plugins/omo/components/rules/dist/context-pressure.js @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +]; +export function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +export function transcriptHasContextPressureMarker(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} diff --git a/plugins/omo/components/rules/dist/debug-log.d.ts b/plugins/omo/components/rules/dist/debug-log.d.ts new file mode 100644 index 0000000..7b98a50 --- /dev/null +++ b/plugins/omo/components/rules/dist/debug-log.d.ts @@ -0,0 +1,8 @@ +type DebugFieldValue = boolean | number | string | null; +type DebugFields = Record; +export interface HookDebugTimer { + lap(phase: string, fields?: DebugFields): void; + done(fields?: DebugFields): void; +} +export declare function createHookDebugTimer(hookName: string): HookDebugTimer; +export {}; diff --git a/plugins/omo/components/rules/dist/debug-log.js b/plugins/omo/components/rules/dist/debug-log.js new file mode 100644 index 0000000..b8ea751 --- /dev/null +++ b/plugins/omo/components/rules/dist/debug-log.js @@ -0,0 +1,36 @@ +import { performance } from "node:perf_hooks"; +import { debuglog } from "node:util"; +const debug = debuglog("codex-rules"); +const noopTimer = { + lap: () => { }, + done: () => { }, +}; +export function createHookDebugTimer(hookName) { + if (!debug.enabled) { + return noopTimer; + } + const startMs = performance.now(); + let lastMs = startMs; + return { + lap: (phase, fields = {}) => { + const nowMs = performance.now(); + writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields); + lastMs = nowMs; + }, + done: (fields = {}) => { + const nowMs = performance.now(); + writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields); + lastMs = nowMs; + }, + }; +} +function writeDebugLine(hookName, phase, durationMs, totalMs, fields) { + debug("%s phase=%s ms=%s total_ms=%s%s", hookName, phase, durationMs.toFixed(3), totalMs.toFixed(3), formatFields(fields)); +} +function formatFields(fields) { + const entries = Object.entries(fields); + if (entries.length === 0) { + return ""; + } + return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`; +} diff --git a/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts new file mode 100644 index 0000000..5e9a40a --- /dev/null +++ b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts @@ -0,0 +1,7 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export interface DynamicTargetFingerprint { + targetPath: string; + cacheKey: string; + fingerprint: string; +} +export declare function fingerprintDynamicTargets(cwd: string, targetPaths: ReadonlyArray, config: PiRulesConfig): DynamicTargetFingerprint[]; diff --git a/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js new file mode 100644 index 0000000..6d17cf4 --- /dev/null +++ b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js @@ -0,0 +1,65 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; +import { hashContent } from "./rules/matcher.js"; +import { sortCandidates } from "./rules/ordering.js"; +import { findProjectRoot } from "./rules/project-root.js"; +import { disabledSourcesFromConfig } from "./rules/sources.js"; +export function fingerprintDynamicTargets(cwd, targetPaths, config) { + const disabledSources = disabledSourcesFromConfig(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints = []; + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions = { + projectRoot, + targetFile: targetPath, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findRuleCandidates(findOptions); + const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); + const cacheKey = dynamicTargetCacheKey(targetPath); + fingerprints.push({ + targetPath, + cacheKey, + fingerprint: hashContent([ + "v1", + config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), + projectRoot ?? "", + cacheKey, + candidateFingerprint, + ].join("\u0000")), + }); + } + return fingerprints; +} +function fingerprintCandidate(candidate) { + return [ + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + fileFingerprint(candidate.path), + ].join("\u0000"); +} +function fileFingerprint(filePath) { + try { + const stats = statSync(filePath, { bigint: true }); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`; + } + catch { + return "missing"; + } +} +function dynamicTargetCacheKey(targetPath) { + return toPosixPath(resolve(targetPath)); +} diff --git a/plugins/omo/components/rules/dist/hook-output.d.ts b/plugins/omo/components/rules/dist/hook-output.d.ts new file mode 100644 index 0000000..d6f800e --- /dev/null +++ b/plugins/omo/components/rules/dist/hook-output.d.ts @@ -0,0 +1,2 @@ +export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; +export declare function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string; diff --git a/plugins/omo/components/rules/dist/hook-output.js b/plugins/omo/components/rules/dist/hook-output.js new file mode 100644 index 0000000..7552862 --- /dev/null +++ b/plugins/omo/components/rules/dist/hook-output.js @@ -0,0 +1,24 @@ +const MAX_ADDITIONAL_CONTEXT_CHARS = 32_000; +export function formatAdditionalContextOutput(eventName, additionalContext) { + const normalizedContext = limitAdditionalContext(normalizeAdditionalContext(additionalContext)); + if (normalizedContext.length === 0) + return ""; + return `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: eventName, + additionalContext: normalizedContext, + }, + })}\n`; +} +function normalizeAdditionalContext(additionalContext) { + return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function limitAdditionalContext(additionalContext) { + if (additionalContext.length <= MAX_ADDITIONAL_CONTEXT_CHARS) + return additionalContext; + const marker = `\n\n[Truncated hook additional context to ${MAX_ADDITIONAL_CONTEXT_CHARS} chars to avoid Codex context overflow.]`; + if (marker.length >= MAX_ADDITIONAL_CONTEXT_CHARS) + return marker.slice(0, MAX_ADDITIONAL_CONTEXT_CHARS); + const head = additionalContext.slice(0, MAX_ADDITIONAL_CONTEXT_CHARS - marker.length).replace(/[ \t\r\n]+$/, ""); + return `${head}${marker}`; +} diff --git a/plugins/omo/components/rules/dist/path-utils.d.ts b/plugins/omo/components/rules/dist/path-utils.d.ts new file mode 100644 index 0000000..6d9ed33 --- /dev/null +++ b/plugins/omo/components/rules/dist/path-utils.d.ts @@ -0,0 +1,4 @@ +export declare function displayPath(cwd: string, filePath: string): string; +export declare function isSameOrChildPath(childPath: string, parentPath: string): boolean; +export declare function toPosixPath(path: string): string; +export declare function uniqueStrings(values: ReadonlyArray): string[]; diff --git a/plugins/omo/components/rules/dist/path-utils.js b/plugins/omo/components/rules/dist/path-utils.js new file mode 100644 index 0000000..9f2a5f7 --- /dev/null +++ b/plugins/omo/components/rules/dist/path-utils.js @@ -0,0 +1,24 @@ +import { isAbsolute, relative, resolve } from "node:path"; +export function displayPath(cwd, filePath) { + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + return toPosixPath(rel); +} +export function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} +export function toPosixPath(path) { + return path.replaceAll("\\", "/"); +} +export function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/plugins/omo/components/rules/dist/persistent-cache.d.ts b/plugins/omo/components/rules/dist/persistent-cache.d.ts new file mode 100644 index 0000000..c48aa79 --- /dev/null +++ b/plugins/omo/components/rules/dist/persistent-cache.d.ts @@ -0,0 +1,13 @@ +import { type PostCompactPendingKind } from "./post-compact-state.js"; +import type { Engine } from "./rules/engine.js"; +export type PostCompactClaimResult = "claimed" | "not-pending" | "contended"; +export declare function hydrateEngineState(engine: Engine, cachePath: string): void; +export declare function persistEngineState(engine: Engine, cachePath: string, completedPostCompactKind?: PostCompactPendingKind): void; +export declare function clearSessionState(cachePath: string): void; +export declare function markSessionCompacted(cachePath: string): void; +export declare function hasPostCompactPending(cachePath: string): boolean; +export declare function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean; +export declare function claimPostCompactPending(cachePath: string, kind: PostCompactPendingKind): PostCompactClaimResult; +export declare function isPostCompactRecoveryInProgress(cachePath: string, kind: PostCompactPendingKind): boolean; +export declare function completePostCompactRecovery(cachePath: string, kind: PostCompactPendingKind): void; +export declare function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string; diff --git a/plugins/omo/components/rules/dist/persistent-cache.js b/plugins/omo/components/rules/dist/persistent-cache.js new file mode 100644 index 0000000..2ec8209 --- /dev/null +++ b/plugins/omo/components/rules/dist/persistent-cache.js @@ -0,0 +1,169 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { postCompactKindState, postCompactPendingKinds, postCompactRecoveringKinds, } from "./post-compact-state.js"; +import { SESSION_STATE_LOCK_CONTENDED, withSessionStateLock } from "./session-state-lock.js"; +export function hydrateEngineState(engine, cachePath) { + const state = readSessionState(cachePath); + engine.state.staticDedup.clear(); + engine.state.dynamicDedup.clear(); + engine.state.dynamicTargetFingerprints.clear(); + for (const key of state.staticDedup) { + engine.state.staticDedup.add(key); + } + for (const [scope, keys] of Object.entries(state.dynamicDedup)) { + engine.state.dynamicDedup.set(scope, new Set(keys)); + } + for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) { + engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint); + } +} +export function persistEngineState(engine, cachePath, completedPostCompactKind) { + const currentState = readSessionState(cachePath); + const dynamicDedup = {}; + for (const [scope, keys] of engine.state.dynamicDedup.entries()) { + dynamicDedup[scope] = [...keys]; + } + const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind); + const postCompactRecovering = nextPostCompactRecovering(currentState, completedPostCompactKind); + writeSessionState(cachePath, { + staticDedup: [...engine.state.staticDedup], + dynamicDedup, + dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()), + ...(postCompactPending === undefined ? {} : { postCompactPending }), + ...(postCompactRecovering === undefined ? {} : { postCompactRecovering }), + }); +} +export function clearSessionState(cachePath) { + rmSync(cachePath, { force: true }); +} +export function markSessionCompacted(cachePath) { + const state = readSessionState(cachePath); + writeSessionState(cachePath, { + staticDedup: state.staticDedup, + dynamicDedup: state.dynamicDedup, + ...(state.dynamicTargetFingerprints === undefined + ? {} + : { dynamicTargetFingerprints: state.dynamicTargetFingerprints }), + postCompactPending: { static: true, dynamic: true }, + }); +} +export function hasPostCompactPending(cachePath) { + const state = readSessionState(cachePath); + return postCompactPendingKinds(state).size > 0 || postCompactRecoveringKinds(state).size > 0; +} +export function isPostCompactPending(cachePath, kind) { + return postCompactPendingKinds(readSessionState(cachePath)).has(kind); +} +export function claimPostCompactPending(cachePath, kind) { + const result = withSessionStateLock(cachePath, () => { + const state = readSessionState(cachePath); + const pendingKinds = postCompactPendingKinds(state); + if (!pendingKinds.has(kind)) { + return "not-pending"; + } + pendingKinds.delete(kind); + const recoveringKinds = postCompactRecoveringKinds(state); + recoveringKinds.add(kind); + writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds)); + return "claimed"; + }); + return result === SESSION_STATE_LOCK_CONTENDED ? "contended" : result; +} +export function isPostCompactRecoveryInProgress(cachePath, kind) { + return postCompactRecoveringKinds(readSessionState(cachePath)).has(kind); +} +export function completePostCompactRecovery(cachePath, kind) { + withSessionStateLock(cachePath, () => { + const state = readSessionState(cachePath); + const pendingKinds = postCompactPendingKinds(state); + const recoveringKinds = postCompactRecoveringKinds(state); + recoveringKinds.delete(kind); + writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds)); + }); +} +export function sessionCachePath(sessionId, pluginDataRoot) { + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules"); + return join(root, "sessions", `${safePathSegment(sessionId)}.json`); +} +function readSessionState(cachePath) { + try { + const parsed = JSON.parse(readFileSync(cachePath, "utf8")); + if (!isSerializedSessionState(parsed)) + return emptyState(); + return parsed; + } + catch { + return emptyState(); + } +} +function writeSessionState(cachePath, state) { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(cachePath, `${JSON.stringify(state)}\n`); +} +function emptyState() { + return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} }; +} +function nextPostCompactPending(state, completedKind) { + const pendingKinds = postCompactPendingKinds(state); + if (completedKind !== undefined) { + pendingKinds.delete(completedKind); + } + if (pendingKinds.size === 0) { + return undefined; + } + return { + ...(pendingKinds.has("static") ? { static: true } : {}), + ...(pendingKinds.has("dynamic") ? { dynamic: true } : {}), + }; +} +function nextPostCompactRecovering(state, completedKind) { + const recoveringKinds = postCompactRecoveringKinds(state); + if (completedKind !== undefined) { + recoveringKinds.delete(completedKind); + } + return postCompactKindState(recoveringKinds); +} +function stateWithPostCompactKinds(state, pendingKinds, recoveringKinds) { + const postCompactPending = postCompactKindState(pendingKinds); + const postCompactRecovering = postCompactKindState(recoveringKinds); + return { + staticDedup: state.staticDedup, + dynamicDedup: state.dynamicDedup, + ...(state.dynamicTargetFingerprints === undefined + ? {} + : { dynamicTargetFingerprints: state.dynamicTargetFingerprints }), + ...(postCompactPending === undefined ? {} : { postCompactPending }), + ...(postCompactRecovering === undefined ? {} : { postCompactRecovering }), + }; +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; +} +function isSerializedSessionState(value) { + if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) { + return false; + } + const staticDedup = value["staticDedup"]; + const dynamicDedup = value["dynamicDedup"]; + const dynamicTargetFingerprints = value["dynamicTargetFingerprints"]; + const postCompactPending = value["postCompactPending"]; + const postCompactRecovering = value["postCompactRecovering"]; + const compacted = value["compacted"]; + return (staticDedup.every((item) => typeof item === "string") && + Object.values(dynamicDedup).every((item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string")) && + (dynamicTargetFingerprints === undefined || + (isRecord(dynamicTargetFingerprints) && + Object.entries(dynamicTargetFingerprints).every(([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string"))) && + (postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) && + (postCompactRecovering === undefined || isPostCompactPendingState(postCompactRecovering)) && + (compacted === undefined || typeof compacted === "boolean")); +} +function isPostCompactPendingState(value) { + return (isRecord(value) && + (value["static"] === undefined || typeof value["static"] === "boolean") && + (value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/rules/dist/post-compact-budget.d.ts b/plugins/omo/components/rules/dist/post-compact-budget.d.ts new file mode 100644 index 0000000..eeffa01 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-budget.d.ts @@ -0,0 +1,6 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export interface PostCompactBudgetContext { + readonly model: string; + readonly transcriptPath: string | null; +} +export declare function withPostCompactBudget(config: PiRulesConfig, context?: PostCompactBudgetContext): PiRulesConfig; diff --git a/plugins/omo/components/rules/dist/post-compact-budget.js b/plugins/omo/components/rules/dist/post-compact-budget.js new file mode 100644 index 0000000..ddc21f8 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-budget.js @@ -0,0 +1,74 @@ +import { hasContextPressureMarker } from "./context-pressure.js"; +import { readTranscriptSearchText } from "./transcript-search.js"; +const DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95; +const ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN = 3; +const PROJECTED_INJECTION_CHARS_PER_TOKEN = 2; +const POST_COMPACT_RESERVED_CONTEXT_PERCENT = 5; +const POST_COMPACT_MIN_RESERVED_TOKENS = 8_000; +const POST_COMPACT_MIN_GUIDE_CHARS = 500; +const FALLBACK_CONTEXT_WINDOW_TOKENS = 200_000; +const MODEL_CONTEXT_BUDGETS = [ + { slug: "gpt-5.5", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT }, + { slug: "gpt-5.4-mini", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT }, + { + slug: "codex-auto-review", + contextWindowTokens: 272_000, + effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + }, +]; +export function withPostCompactBudget(config, context) { + const postCompactMaxResultChars = dynamicPostCompactMaxResultChars(context) ?? config.postCompactMaxResultChars; + const maxResultChars = Math.min(config.maxResultChars, config.postCompactMaxResultChars, postCompactMaxResultChars); + const maxRuleChars = Math.min(config.maxRuleChars, config.postCompactMaxRuleChars, maxResultChars); + return { + ...config, + maxRuleChars, + maxResultChars, + }; +} +function dynamicPostCompactMaxResultChars(context) { + if (context === undefined || context.transcriptPath === null) { + return undefined; + } + const transcript = estimateTranscript(context.transcriptPath); + if (transcript === undefined) { + return undefined; + } + if (hasContextPressureMarker(transcript.text)) { + return POST_COMPACT_MIN_GUIDE_CHARS; + } + const modelBudget = modelContextBudgetFor(context.model) ?? fallbackModelContextBudget(); + const effectiveContextWindow = Math.floor((modelBudget.contextWindowTokens * modelBudget.effectivePercent) / 100); + const reservedTokens = Math.max(POST_COMPACT_MIN_RESERVED_TOKENS, Math.floor((effectiveContextWindow * POST_COMPACT_RESERVED_CONTEXT_PERCENT) / 100)); + const injectableTokens = Math.max(0, effectiveContextWindow - reservedTokens - transcript.tokens); + return Math.max(POST_COMPACT_MIN_GUIDE_CHARS, Math.floor(injectableTokens * PROJECTED_INJECTION_CHARS_PER_TOKEN)); +} +function modelContextBudgetFor(model) { + const normalizedModel = model.trim().toLowerCase(); + for (const budget of MODEL_CONTEXT_BUDGETS) { + if (normalizedModel === budget.slug || + normalizedModel.endsWith(`.${budget.slug}`) || + normalizedModel.endsWith(`/${budget.slug}`)) { + return budget; + } + } + return undefined; +} +function fallbackModelContextBudget() { + return { + slug: "unknown", + contextWindowTokens: FALLBACK_CONTEXT_WINDOW_TOKENS, + effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + }; +} +function estimateTranscript(transcriptPath) { + const transcriptText = readTranscriptSearchText(transcriptPath, { latestCompactedReplacementOnly: true }) ?? + readTranscriptSearchText(transcriptPath); + if (transcriptText === null) { + return undefined; + } + return { + text: transcriptText, + tokens: Math.ceil(Buffer.byteLength(transcriptText, "utf8") / ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN), + }; +} diff --git a/plugins/omo/components/rules/dist/post-compact-claim.d.ts b/plugins/omo/components/rules/dist/post-compact-claim.d.ts new file mode 100644 index 0000000..101b277 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-claim.d.ts @@ -0,0 +1,4 @@ +import type { PostCompactClaimResult } from "./persistent-cache.js"; +import type { PostCompactPendingKind } from "./post-compact-state.js"; +export declare function claimedPostCompactKind(result: PostCompactClaimResult, kind: T): T | undefined; +export declare function shouldSkipPostCompactClaim(result: PostCompactClaimResult, recoveryInProgress: boolean): boolean; diff --git a/plugins/omo/components/rules/dist/post-compact-claim.js b/plugins/omo/components/rules/dist/post-compact-claim.js new file mode 100644 index 0000000..b936f73 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-claim.js @@ -0,0 +1,6 @@ +export function claimedPostCompactKind(result, kind) { + return result === "claimed" ? kind : undefined; +} +export function shouldSkipPostCompactClaim(result, recoveryInProgress) { + return result === "contended" || (result === "not-pending" && recoveryInProgress); +} diff --git a/plugins/omo/components/rules/dist/post-compact-state.d.ts b/plugins/omo/components/rules/dist/post-compact-state.d.ts new file mode 100644 index 0000000..5a20a81 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-state.d.ts @@ -0,0 +1,13 @@ +export type PostCompactPendingKind = "static" | "dynamic"; +export interface PostCompactPendingState { + static?: boolean; + dynamic?: boolean; +} +export interface PostCompactStateFields { + readonly postCompactPending?: PostCompactPendingState; + readonly postCompactRecovering?: PostCompactPendingState; + readonly compacted?: boolean; +} +export declare function postCompactKindState(kinds: ReadonlySet): PostCompactPendingState | undefined; +export declare function postCompactPendingKinds(state: PostCompactStateFields): Set; +export declare function postCompactRecoveringKinds(state: PostCompactStateFields): Set; diff --git a/plugins/omo/components/rules/dist/post-compact-state.js b/plugins/omo/components/rules/dist/post-compact-state.js new file mode 100644 index 0000000..6953252 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-state.js @@ -0,0 +1,29 @@ +export function postCompactKindState(kinds) { + if (kinds.size === 0) { + return undefined; + } + return { + ...(kinds.has("static") ? { static: true } : {}), + ...(kinds.has("dynamic") ? { dynamic: true } : {}), + }; +} +export function postCompactPendingKinds(state) { + const pendingKinds = new Set(); + if (state.compacted === true || state.postCompactPending?.static === true) { + pendingKinds.add("static"); + } + if (state.compacted === true || state.postCompactPending?.dynamic === true) { + pendingKinds.add("dynamic"); + } + return pendingKinds; +} +export function postCompactRecoveringKinds(state) { + const recoveringKinds = new Set(); + if (state.postCompactRecovering?.static === true) { + recoveringKinds.add("static"); + } + if (state.postCompactRecovering?.dynamic === true) { + recoveringKinds.add("dynamic"); + } + return recoveringKinds; +} diff --git a/plugins/omo/components/rules/dist/rules-engine-factory.d.ts b/plugins/omo/components/rules/dist/rules-engine-factory.d.ts new file mode 100644 index 0000000..aef9fb9 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules-engine-factory.d.ts @@ -0,0 +1,6 @@ +interface RulesEngineFactoryOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +} +export declare function createRulesEngine(options: RulesEngineFactoryOptions, config?: import("./rules/types.js").PiRulesConfig): import("./rules/engine-types.js").Engine; +export {}; diff --git a/plugins/omo/components/rules/dist/rules-engine-factory.js b/plugins/omo/components/rules/dist/rules-engine-factory.js new file mode 100644 index 0000000..bebcce0 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules-engine-factory.js @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { configFromEnvironment } from "./config.js"; +import { createEngine } from "./rules/engine.js"; +import { findRuleCandidates } from "./rules/finder.js"; +import { findProjectRoot } from "./rules/project-root.js"; +export function createRulesEngine(options, config = configFromEnvironment(options.env)) { + const platform = options.platform ?? process.platform; + return createEngine(config, { + findCandidates: (finderOptions) => findRuleCandidates({ ...finderOptions, platform }), + findProjectRoot, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } + catch { + return null; + } + }, + }); +} diff --git a/plugins/omo/components/rules/dist/rules/cache.d.ts b/plugins/omo/components/rules/dist/rules/cache.d.ts new file mode 100644 index 0000000..03c922e --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/cache.d.ts @@ -0,0 +1,9 @@ +import type { LoadedRule, SessionState } from "./types.js"; +export declare function createSessionState(cwd?: string): SessionState; +export declare function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string; +export declare function dynamicDedupKey(rulePath: string, contentHash: string): string; +export declare function markStaticInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function isStaticInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function clearSession(state: SessionState): void; diff --git a/plugins/omo/components/rules/dist/rules/cache.js b/plugins/omo/components/rules/dist/rules/cache.js new file mode 100644 index 0000000..459b78c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/cache.js @@ -0,0 +1,51 @@ +const DYNAMIC_SESSION_KEY = "__pi-rules-session__"; +export function createSessionState(cwd) { + return { + cwd, + staticDedup: new Set(), + dynamicDedup: new Map(), + dynamicTargetFingerprints: new Map(), + loadedRules: [], + diagnostics: [], + }; +} +export function staticDedupKey(cwd, rulePath, contentHash) { + return `${cwd}::${rulePath}::${contentHash}`; +} +export function dynamicDedupKey(rulePath, contentHash) { + return `${rulePath}::${contentHash}`; +} +export function markStaticInjected(state, rule) { + const key = staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash); + if (state.staticDedup.has(key)) { + return false; + } + state.staticDedup.add(key); + return true; +} +export function markDynamicInjected(state, rule) { + let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY); + if (keys === undefined) { + keys = new Set(); + state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys); + } + const key = dynamicDedupKey(rule.realPath, rule.contentHash); + if (keys.has(key)) { + return false; + } + keys.add(key); + return true; +} +export function isStaticInjected(state, rule) { + return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash)); +} +export function isDynamicInjected(state, rule) { + return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true; +} +export function clearSession(state) { + state.staticDedup.clear(); + state.dynamicDedup.clear(); + state.dynamicTargetFingerprints.clear(); + state.loadedRules.length = 0; + state.diagnostics.length = 0; +} diff --git a/plugins/omo/components/rules/dist/rules/constants.d.ts b/plugins/omo/components/rules/dist/rules/constants.d.ts new file mode 100644 index 0000000..d6d2927 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/constants.d.ts @@ -0,0 +1,58 @@ +import type { RuleSource } from "./types.js"; +/** + * Project root marker files / directories used by `findProjectRoot`. + * Walks UP from cwd until any of these is found in the directory. + */ +export declare const PROJECT_MARKERS: readonly string[]; +/** + * Project rule subdirectories. First tuple element is the parent dir under + * the project root, second is the subdir scanned recursively. + */ +export declare const PROJECT_RULE_SUBDIRS: ReadonlyArray; +/** + * Single-file project rules (always apply, frontmatter optional). + */ +export declare const PROJECT_SINGLE_FILES: readonly string[]; +/** + * User-home rule directories. + */ +export declare const USER_HOME_RULE_SUBDIRS: readonly string[]; +/** + * User-home single-file rules. The first one to exist wins per "first-match" semantics. + */ +export declare const USER_HOME_SINGLE_FILES: readonly string[]; +/** + * Bundled plugin rule directory relative to the rules component root. + */ +export declare const BUNDLED_RULE_SUBDIR = "bundled-rules"; +/** + * File extensions accepted as rule files in scanned directories. + */ +export declare const RULE_FILE_EXTENSIONS: readonly string[]; +/** + * Per-rule source priority for deterministic ordering. Lower = earlier. + */ +export declare const SOURCE_PRIORITY: ReadonlyMap; +/** + * Distance value assigned to global / user-home rules. + */ +export declare const GLOBAL_DISTANCE = 9999; +/** + * Per-rule body character cap (default). + */ +export declare const DEFAULT_MAX_RULE_CHARS = 12000; +export declare const DEFAULT_MAX_SCAN_FILES = 1000; +/** + * Total injected chars per tool result (default). + */ +export declare const DEFAULT_MAX_RESULT_CHARS = 40000; +export declare const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 3500; +export declare const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 4000; +/** + * Truncation marker template. `{path}` is replaced with the relative path. + */ +export declare const TRUNCATION_NOTICE = "\n\n[Truncated. Full: {path}]"; +/** + * Directories excluded by the recursive scanner regardless of glob settings. + */ +export declare const SCANNER_EXCLUDED_DIRS: readonly string[]; diff --git a/plugins/omo/components/rules/dist/rules/constants.js b/plugins/omo/components/rules/dist/rules/constants.js new file mode 100644 index 0000000..1b5fd51 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/constants.js @@ -0,0 +1,89 @@ +/** + * Project root marker files / directories used by `findProjectRoot`. + * Walks UP from cwd until any of these is found in the directory. + */ +export const PROJECT_MARKERS = [ + ".git", + "pnpm-workspace.yaml", + "package.json", + "pyproject.toml", + "Cargo.toml", + "go.mod", + ".venv", +]; +/** + * Project rule subdirectories. First tuple element is the parent dir under + * the project root, second is the subdir scanned recursively. + */ +export const PROJECT_RULE_SUBDIRS = [ + [".omo", "rules"], + [".claude", "rules"], + [".cursor", "rules"], + [".github", "instructions"], +]; +/** + * Single-file project rules (always apply, frontmatter optional). + */ +export const PROJECT_SINGLE_FILES = [".github/copilot-instructions.md", "CONTEXT.md"]; +/** + * User-home rule directories. + */ +export const USER_HOME_RULE_SUBDIRS = [".omo/rules", ".opencode/rules", ".claude/rules"]; +/** + * User-home single-file rules. The first one to exist wins per "first-match" semantics. + */ +export const USER_HOME_SINGLE_FILES = []; +/** + * Bundled plugin rule directory relative to the rules component root. + */ +export const BUNDLED_RULE_SUBDIR = "bundled-rules"; +/** + * File extensions accepted as rule files in scanned directories. + */ +export const RULE_FILE_EXTENSIONS = [".md", ".mdc"]; +/** + * Per-rule source priority for deterministic ordering. Lower = earlier. + */ +export const SOURCE_PRIORITY = new Map([ + [".omo/rules", 0], + [".claude/rules", 1], + [".cursor/rules", 2], + [".github/instructions", 3], + [".github/copilot-instructions.md", 4], + ["CONTEXT.md", 7], + ["~/.omo/rules", 100], + ["~/.opencode/rules", 101], + ["~/.claude/rules", 102], + ["plugin-bundled", 200], +]); +/** + * Distance value assigned to global / user-home rules. + */ +export const GLOBAL_DISTANCE = 9999; +/** + * Per-rule body character cap (default). + */ +export const DEFAULT_MAX_RULE_CHARS = 12000; +export const DEFAULT_MAX_SCAN_FILES = 1000; +/** + * Total injected chars per tool result (default). + */ +export const DEFAULT_MAX_RESULT_CHARS = 40000; +export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 3500; +export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 4000; +/** + * Truncation marker template. `{path}` is replaced with the relative path. + */ +export const TRUNCATION_NOTICE = "\n\n[Truncated. Full: {path}]"; +/** + * Directories excluded by the recursive scanner regardless of glob settings. + */ +export const SCANNER_EXCLUDED_DIRS = [ + "node_modules", + ".git", + "dist", + "build", + ".turbo", + ".next", + "coverage", +]; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts new file mode 100644 index 0000000..4d43aad --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts @@ -0,0 +1,5 @@ +import type { CandidateDiscoveryCache, DynamicMatchCache, EngineDeps } from "./engine-types.js"; +import type { matchRule } from "./matcher.js"; +import type { LoadedRule, MatchReason, RuleCandidate } from "./types.js"; +export declare function matchDynamicRuleCached(cache: DynamicMatchCache, projectRoot: string | null, targetFile: string, candidate: RuleCandidate, loadedRule: LoadedRule, matchRuleImpl: typeof matchRule): MatchReason | null; +export declare function findSortedCandidatesCached(cache: CandidateDiscoveryCache, findCandidates: EngineDeps["findCandidates"], options: Parameters[0]): RuleCandidate[]; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js new file mode 100644 index 0000000..748a64a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js @@ -0,0 +1,60 @@ +import { dirname, resolve } from "node:path"; +import { pathBasesForTarget, toPosixPath } from "./engine-paths.js"; +import { sortCandidates } from "./ordering.js"; +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; +export function matchDynamicRuleCached(cache, projectRoot, targetFile, candidate, loadedRule, matchRuleImpl) { + const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash); + if (cache.has(cacheKey)) { + const cachedReason = cache.get(cacheKey) ?? null; + cache.delete(cacheKey); + cache.set(cacheKey, cachedReason); + return cachedReason; + } + const matchResult = matchRuleImpl({ + frontmatter: loadedRule.frontmatter, + isSingleFile: candidate.isSingleFile, + pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), + }); + const reason = matchResult.matched ? matchResult.reason : null; + setDynamicMatchCacheEntry(cache, cacheKey, reason); + return reason; +} +export function findSortedCandidatesCached(cache, findCandidates, options) { + const cacheKey = candidateDiscoveryCacheKey(options); + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const candidates = sortCandidates(findCandidates(options)); + cache.set(cacheKey, candidates); + return candidates; +} +function setDynamicMatchCacheEntry(cache, cacheKey, reason) { + if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value; + if (oldestCacheKey !== undefined) { + cache.delete(oldestCacheKey); + } + } + cache.set(cacheKey, reason); +} +function dynamicMatchCacheKey(projectRoot, targetFile, candidate, contentHash) { + return [ + projectRoot ?? "", + toPosixPath(resolve(targetFile)), + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + contentHash, + ].join("\0"); +} +function candidateDiscoveryCacheKey(options) { + return [ + options.projectRoot ?? "", + options.targetFile === null ? "" : dirname(resolve(options.targetFile)), + ...[...(options.disabledSources ?? [])].sort(), + ].join("\0"); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts new file mode 100644 index 0000000..ca69227 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts @@ -0,0 +1,6 @@ +import type { DynamicMatchCache, EngineDeps } from "./engine-types.js"; +import type { LoadedRule, PiRulesConfig, RuleDiagnostic } from "./types.js"; +export declare function loadDynamicCandidates(config: PiRulesConfig, deps: EngineDeps, cwd: string, targetPaths: ReadonlyArray, dynamicMatchCache: DynamicMatchCache): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +}; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js new file mode 100644 index 0000000..761c3a5 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js @@ -0,0 +1,61 @@ +import { findSortedCandidatesCached, matchDynamicRuleCached } from "./engine-dynamic-cache.js"; +import { loadCandidate, ruleDedupKey } from "./engine-loader.js"; +import { isSameOrChildPath } from "./engine-paths.js"; +import { createRuleDiscoveryCache } from "./finder.js"; +import { matchRule } from "./matcher.js"; +import { sortCandidates } from "./ordering.js"; +import { disabledSourcesFromConfig } from "./sources.js"; +export function loadDynamicCandidates(config, deps, cwd, targetPaths, dynamicMatchCache) { + const rules = []; + const diagnostics = []; + const seenRules = new Set(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); + const disabledSources = disabledSourcesFromConfig(config); + const discoveryCache = createRuleDiscoveryCache(); + const candidateDiscoveryCache = new Map(); + const cwdProjectRoot = deps.findProjectRoot(cwd); + for (const targetFile of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot) + ? cwdProjectRoot + : deps.findProjectRoot(targetFile); + const findOptions = { + projectRoot, + targetFile, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findSortedCandidatesCached(candidateDiscoveryCache, deps.findCandidates, findOptions); + for (const candidate of candidates) { + const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership); + if (loadedRule === null) { + continue; + } + const matchReason = matchDynamicRuleCached(dynamicMatchCache, projectRoot, targetFile, candidate, loadedRule, deps.matchRule ?? matchRule); + if (matchReason === null) { + continue; + } + const dedupKey = ruleDedupKey(loadedRule); + if (seenRules.has(dedupKey)) { + continue; + } + seenRules.add(dedupKey); + rules.push({ ...loadedRule, matchReason }); + } + } + return { rules: sortCandidates(rules), diagnostics }; +} +function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-loader.d.ts new file mode 100644 index 0000000..bfd3a10 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-loader.d.ts @@ -0,0 +1,7 @@ +import type { CandidateProjectMembership, EngineDeps, LoadedRuleContent } from "./engine-types.js"; +import type { LoadedRule, MatchReason, RuleCandidate, RuleDiagnostic } from "./types.js"; +export declare function loadCandidate(candidate: RuleCandidate, deps: EngineDeps, diagnostics: RuleDiagnostic[], projectRoot: string | null, loadedRuleContent?: Map, projectMembership?: CandidateProjectMembership): (LoadedRule & { + matchReason: MatchReason; +}) | null; +export declare function ruleDedupKey(rule: LoadedRule): string; +export declare function staticMatchReason(rule: LoadedRule): MatchReason | null; diff --git a/plugins/omo/components/rules/dist/rules/engine-loader.js b/plugins/omo/components/rules/dist/rules/engine-loader.js new file mode 100644 index 0000000..0e288d0 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-loader.js @@ -0,0 +1,60 @@ +import { isCandidateWithinProjectCached } from "./engine-paths.js"; +import { hashContent } from "./matcher.js"; +import { parseRule } from "./parser.js"; +export function loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership) { + if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) { + diagnostics.push({ + severity: "warning", + source: candidate.path, + message: "Rule file resolves outside project root", + }); + return null; + } + const cachedContent = loadedRuleContent?.get(candidate.realPath); + if (cachedContent !== undefined) { + return loadedRuleFromContent(candidate, cachedContent, diagnostics); + } + const content = deps.readFile(candidate.path); + if (content === null) { + loadedRuleContent?.set(candidate.realPath, null); + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + const parsed = parseRule(content); + const loadedContent = { + frontmatter: parsed.frontmatter, + body: parsed.body, + contentHash: hashContent(content), + ...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }), + }; + loadedRuleContent?.set(candidate.realPath, loadedContent); + return loadedRuleFromContent(candidate, loadedContent, diagnostics); +} +export function ruleDedupKey(rule) { + return `${rule.realPath}::${rule.contentHash}`; +} +export function staticMatchReason(rule) { + if (rule.frontmatter.alwaysApply === true) { + return "alwaysApply"; + } + if (rule.isSingleFile) { + return "single-file"; + } + return null; +} +function loadedRuleFromContent(candidate, content, diagnostics) { + if (content === null) { + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + if (content.diagnostic !== undefined) { + diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic }); + } + return { + ...candidate, + frontmatter: content.frontmatter, + body: content.body, + contentHash: content.contentHash, + matchReason: { kind: "no-match" }, + }; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-paths.d.ts b/plugins/omo/components/rules/dist/rules/engine-paths.d.ts new file mode 100644 index 0000000..85e49ec --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-paths.d.ts @@ -0,0 +1,11 @@ +import type { CandidateProjectMembership } from "./engine-types.js"; +import type { RuleCandidate } from "./types.js"; +export declare function isCandidateWithinProjectCached(candidate: RuleCandidate, projectRoot: string | null, projectMembership: CandidateProjectMembership | undefined): boolean; +export declare function isSameOrChildPath(childPath: string, parentPath: string): boolean; +export declare function isRootSingleFile(candidate: RuleCandidate): boolean; +export declare function pathBasesForTarget(projectRoot: string | null, targetFile: string, candidate: RuleCandidate): { + projectRelative: string; + scopeRelative?: string; + basename: string; +}; +export declare function toPosixPath(path: string): string; diff --git a/plugins/omo/components/rules/dist/rules/engine-paths.js b/plugins/omo/components/rules/dist/rules/engine-paths.js new file mode 100644 index 0000000..bc26db3 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-paths.js @@ -0,0 +1,75 @@ +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { PROJECT_SINGLE_FILES } from "./constants.js"; +const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/"))); +export function isCandidateWithinProjectCached(candidate, projectRoot, projectMembership) { + if (projectMembership === undefined) { + return isCandidateWithinProject(candidate, projectRoot); + } + const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`; + const cached = projectMembership.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const isWithinProject = isCandidateWithinProject(candidate, projectRoot); + projectMembership.set(cacheKey, isWithinProject); + return isWithinProject; +} +export function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} +export function isRootSingleFile(candidate) { + return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source); +} +export function pathBasesForTarget(projectRoot, targetFile, candidate) { + const targetBasename = basename(targetFile); + if (projectRoot === null) { + return { projectRelative: targetBasename, basename: targetBasename }; + } + const projectRelative = toPosixPath(relative(projectRoot, targetFile)); + const scopeDirectory = scopeDirectoryForCandidate(projectRoot, candidate); + if (scopeDirectory === null) { + return { projectRelative, basename: targetBasename }; + } + return { + projectRelative, + scopeRelative: toPosixPath(relative(scopeDirectory, targetFile)), + basename: targetBasename, + }; +} +export function toPosixPath(path) { + return path.replaceAll("\\", "/"); +} +function isCandidateWithinProject(candidate, projectRoot) { + if (candidate.isGlobal) { + return true; + } + if (projectRoot === null) { + return false; + } + const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath)); + return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath)); +} +function realPathOrResolved(path) { + try { + return realpathSync.native(path); + } + catch { + return resolve(path); + } +} +function scopeDirectoryForCandidate(projectRoot, candidate) { + if (candidate.isGlobal) { + return null; + } + if (candidate.isSingleFile) { + return dirname(candidate.path); + } + const sourceIndex = candidate.relativePath.indexOf(candidate.source); + if (sourceIndex === -1) { + return projectRoot; + } + const scopeRelativeDirectory = candidate.relativePath.slice(0, sourceIndex).replace(/\/$/, ""); + return scopeRelativeDirectory.length === 0 ? projectRoot : join(projectRoot, scopeRelativeDirectory); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts new file mode 100644 index 0000000..63e2884 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts @@ -0,0 +1,6 @@ +import type { EngineDeps } from "./engine-types.js"; +import type { LoadedRule, RuleCandidate, RuleDiagnostic } from "./types.js"; +export declare function loadStaticCandidates(candidates: ReadonlyArray, deps: EngineDeps, projectRoot: string | null): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +}; diff --git a/plugins/omo/components/rules/dist/rules/engine-static-loader.js b/plugins/omo/components/rules/dist/rules/engine-static-loader.js new file mode 100644 index 0000000..3643734 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-static-loader.js @@ -0,0 +1,29 @@ +import { loadCandidate, staticMatchReason } from "./engine-loader.js"; +import { isRootSingleFile } from "./engine-paths.js"; +import { sortCandidates } from "./ordering.js"; +export function loadStaticCandidates(candidates, deps, projectRoot) { + const rules = []; + const diagnostics = []; + let rootSingleFileSelected = false; + for (const candidate of sortCandidates(candidates)) { + if (isDedupedRootSingleFile(candidate, rootSingleFileSelected)) { + continue; + } + const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot); + if (loadedRule === null) { + continue; + } + const matchReason = staticMatchReason(loadedRule); + if (matchReason === null) { + continue; + } + if (isRootSingleFile(candidate)) { + rootSingleFileSelected = true; + } + rules.push({ ...loadedRule, matchReason }); + } + return { rules: sortCandidates(rules), diagnostics }; +} +function isDedupedRootSingleFile(candidate, rootSingleFileSelected) { + return rootSingleFileSelected && isRootSingleFile(candidate); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-types.d.ts b/plugins/omo/components/rules/dist/rules/engine-types.d.ts new file mode 100644 index 0000000..f6c4772 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-types.d.ts @@ -0,0 +1,44 @@ +import type { RuleDiscoveryCache } from "./finder.js"; +import type { matchRule } from "./matcher.js"; +import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js"; +export interface LoadedRuleContent { + frontmatter: LoadedRule["frontmatter"]; + body: string; + contentHash: string; + diagnostic?: string; +} +export type CandidateProjectMembership = Map; +export type CandidateDiscoveryCache = Map; +export type DynamicMatchCache = Map; +export interface EngineDeps { + findCandidates: (options: { + projectRoot: string | null; + targetFile: string | null; + homeDir?: string; + disabledSources?: ReadonlySet; + skipUserHome?: boolean; + cache?: RuleDiscoveryCache; + }) => RuleCandidate[]; + readFile: (path: string) => string | null; + findProjectRoot: (startPath: string) => string | null; + matchRule?: typeof matchRule; +} +export interface Engine { + state: SessionState; + config: PiRulesConfig; + loadStaticRules(cwd: string): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; + }; + loadDynamicRules(cwd: string, targetPaths: ReadonlyArray): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; + }; + formatStatic(rules: ReadonlyArray): string; + formatDynamic(rules: ReadonlyArray, target: string): string; + resetSession(cwd?: string): void; + isStaticInjected(rule: LoadedRule): boolean; + isDynamicInjected(rule: LoadedRule): boolean; + markStaticInjected(rule: LoadedRule): boolean; + markDynamicInjected(rule: LoadedRule): boolean; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-types.js b/plugins/omo/components/rules/dist/rules/engine-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/rules/dist/rules/engine.d.ts b/plugins/omo/components/rules/dist/rules/engine.d.ts new file mode 100644 index 0000000..1a6a371 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine.d.ts @@ -0,0 +1,5 @@ +import type { Engine, EngineDeps } from "./engine-types.js"; +import type { PiRulesConfig } from "./types.js"; +export type { Engine, EngineDeps } from "./engine-types.js"; +export declare function defaultConfig(): PiRulesConfig; +export declare function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine; diff --git a/plugins/omo/components/rules/dist/rules/engine.js b/plugins/omo/components/rules/dist/rules/engine.js new file mode 100644 index 0000000..fa01165 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine.js @@ -0,0 +1,81 @@ +import { clearSession, createSessionState, isDynamicInjected as isDynamicInjectedInState, isStaticInjected as isStaticInjectedInState, markDynamicInjected as markDynamicInjectedInState, markStaticInjected as markStaticInjectedInState, } from "./cache.js"; +import { DEFAULT_MAX_RESULT_CHARS, DEFAULT_MAX_RULE_CHARS, DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, DEFAULT_POST_COMPACT_MAX_RULE_CHARS, } from "./constants.js"; +import { loadDynamicCandidates } from "./engine-dynamic-loader.js"; +import { loadStaticCandidates } from "./engine-static-loader.js"; +import { formatDynamicBlock, formatStaticBlock } from "./formatter.js"; +import { disabledSourcesFromConfig } from "./sources.js"; +export function defaultConfig() { + return { + disabled: false, + mode: "both", + maxRuleChars: DEFAULT_MAX_RULE_CHARS, + maxResultChars: DEFAULT_MAX_RESULT_CHARS, + postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS, + postCompactMaxResultChars: DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, + enabledSources: "auto", + }; +} +export function createEngine(config, deps) { + const state = createSessionState(); + const dynamicMatchCache = new Map(); + function loadStaticRules(cwd) { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + return emptyLoadResult(state); + } + const projectRoot = deps.findProjectRoot(cwd); + const findOptions = { + projectRoot, + targetFile: null, + }; + const disabledSources = disabledSourcesFromConfig(config); + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); + const result = loadStaticCandidates(candidates, deps, projectRoot); + storeLastLoad(state, result.rules, result.diagnostics); + return result; + } + function loadDynamicRules(cwd, targetPaths) { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) { + return emptyLoadResult(state); + } + const result = loadDynamicCandidates(config, deps, cwd, targetPaths, dynamicMatchCache); + storeLastLoad(state, result.rules, result.diagnostics); + return result; + } + return { + state, + config, + loadStaticRules, + loadDynamicRules, + formatStatic: (rules) => formatStaticBlock(rules, { maxRuleChars: config.maxRuleChars, maxResultChars: config.maxResultChars }), + formatDynamic: (rules, target) => formatDynamicBlock(rules, target, { + maxRuleChars: config.maxRuleChars, + maxResultChars: config.maxResultChars, + }), + resetSession: (cwd) => { + clearSession(state); + dynamicMatchCache.clear(); + if (cwd !== undefined) { + state.cwd = cwd; + } + }, + isStaticInjected: (rule) => isStaticInjectedInState(state, rule), + isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule), + markStaticInjected: (rule) => markStaticInjectedInState(state, rule), + markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule), + }; +} +function storeLastLoad(state, rules, diagnostics) { + state.loadedRules.length = 0; + state.loadedRules.push(...rules); + state.diagnostics.length = 0; + state.diagnostics.push(...diagnostics); +} +function emptyLoadResult(state) { + storeLastLoad(state, [], []); + return { rules: [], diagnostics: [] }; +} diff --git a/plugins/omo/components/rules/dist/rules/errors.d.ts b/plugins/omo/components/rules/dist/rules/errors.d.ts new file mode 100644 index 0000000..cc98d46 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/errors.d.ts @@ -0,0 +1,6 @@ +export declare class UnsupportedRuleSourceError extends Error { + constructor(message: string); +} +export declare class RuleFrontmatterParseError extends Error { + constructor(message: string); +} diff --git a/plugins/omo/components/rules/dist/rules/errors.js b/plugins/omo/components/rules/dist/rules/errors.js new file mode 100644 index 0000000..0fb83e8 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/errors.js @@ -0,0 +1,12 @@ +export class UnsupportedRuleSourceError extends Error { + constructor(message) { + super(message); + this.name = "UnsupportedRuleSourceError"; + } +} +export class RuleFrontmatterParseError extends Error { + constructor(message) { + super(message); + this.name = "RuleFrontmatterParseError"; + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder-cache.d.ts b/plugins/omo/components/rules/dist/rules/finder-cache.d.ts new file mode 100644 index 0000000..33e716c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-cache.d.ts @@ -0,0 +1,14 @@ +import { scanRuleFiles } from "./scanner.js"; +type ScannedRuleFiles = ReturnType; +interface SingleFileInfo { + readonly path: string; + readonly realPath: string; +} +export interface RuleDiscoveryCache { + readonly scannedRuleFiles: Map; + readonly singleFileInfo: Map; +} +export declare function createRuleDiscoveryCache(): RuleDiscoveryCache; +export declare function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles; +export declare function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null; +export {}; diff --git a/plugins/omo/components/rules/dist/rules/finder-cache.js b/plugins/omo/components/rules/dist/rules/finder-cache.js new file mode 100644 index 0000000..3964f6f --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-cache.js @@ -0,0 +1,51 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { scanRuleFiles } from "./scanner.js"; +export function createRuleDiscoveryCache() { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} +export function scanRuleFilesCached(rootDir, cache) { + if (cache === undefined) { + return scanRuleFiles({ rootDir }); + } + const cached = cache.scannedRuleFiles.get(rootDir); + if (cached !== undefined) { + return cached; + } + const scannedFiles = scanRuleFiles({ rootDir }); + cache.scannedRuleFiles.set(rootDir, scannedFiles); + return scannedFiles; +} +export function singleFileInfoCached(filePath, cache) { + if (cache === undefined) { + return readSingleFileInfo(filePath); + } + const cached = cache.singleFileInfo.get(filePath); + if (cached !== undefined) { + return cached; + } + const fileInfo = readSingleFileInfo(filePath); + cache.singleFileInfo.set(filePath, fileInfo); + return fileInfo; +} +function readSingleFileInfo(filePath) { + if (!existsSync(filePath)) { + return null; + } + try { + if (!statSync(filePath).isFile()) { + return null; + } + return { path: filePath, realPath: resolveRealPath(filePath) }; + } + catch { + return null; + } +} +function resolveRealPath(filePath) { + try { + return realpathSync.native(filePath); + } + catch { + return filePath; + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder-paths.d.ts b/plugins/omo/components/rules/dist/rules/finder-paths.d.ts new file mode 100644 index 0000000..e112d76 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-paths.d.ts @@ -0,0 +1,6 @@ +export interface WalkDirectory { + readonly directory: string; + readonly distance: number; +} +export declare function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[]; +export declare function toRelativePath(rootDirectory: string, filePath: string): string; diff --git a/plugins/omo/components/rules/dist/rules/finder-paths.js b/plugins/omo/components/rules/dist/rules/finder-paths.js new file mode 100644 index 0000000..93c7ae2 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-paths.js @@ -0,0 +1,33 @@ +import { dirname, posix, relative, resolve } from "node:path"; +export function getWalkDirectories(projectRoot, targetFile) { + if (targetFile === null) { + return [{ directory: projectRoot, distance: 0 }]; + } + const startDirectory = dirname(resolve(targetFile)); + if (!isSameOrChildPath(startDirectory, projectRoot)) { + return [{ directory: projectRoot, distance: 0 }]; + } + const walkDirectories = []; + let currentDirectory = startDirectory; + let distance = 0; + while (true) { + walkDirectories.push({ directory: currentDirectory, distance }); + if (currentDirectory === projectRoot) { + break; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + currentDirectory = parentDirectory; + distance += 1; + } + return walkDirectories; +} +export function toRelativePath(rootDirectory, filePath) { + return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/")); +} +function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, childPath); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); +} diff --git a/plugins/omo/components/rules/dist/rules/finder-sources.d.ts b/plugins/omo/components/rules/dist/rules/finder-sources.d.ts new file mode 100644 index 0000000..b845c3c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-sources.d.ts @@ -0,0 +1,5 @@ +import type { RuleSource } from "./types.js"; +export declare function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource; +export declare function toProjectSingleFileSource(ruleFile: string): RuleSource; +export declare function toUserHomeRuleSource(ruleSubdir: string): RuleSource; +export declare function toUserHomeSingleFileSource(ruleFile: string): RuleSource; diff --git a/plugins/omo/components/rules/dist/rules/finder-sources.js b/plugins/omo/components/rules/dist/rules/finder-sources.js new file mode 100644 index 0000000..e798b7b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-sources.js @@ -0,0 +1,40 @@ +import { UnsupportedRuleSourceError } from "./errors.js"; +export function toProjectRuleSource(parentDirectory, subDirectory) { + const source = `${parentDirectory}/${subDirectory}`; + switch (source) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`); + } +} +export function toProjectSingleFileSource(ruleFile) { + switch (ruleFile) { + case ".github/copilot-instructions.md": + case "CONTEXT.md": + return ruleFile; + default: + throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`); + } +} +export function toUserHomeRuleSource(ruleSubdir) { + const source = `~/${ruleSubdir}`; + switch (source) { + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`); + } +} +export function toUserHomeSingleFileSource(ruleFile) { + const source = `~/${ruleFile}`; + switch (source) { + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder.d.ts b/plugins/omo/components/rules/dist/rules/finder.d.ts new file mode 100644 index 0000000..bd1c5f7 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder.d.ts @@ -0,0 +1,28 @@ +import { type RuleDiscoveryCache } from "./finder-cache.js"; +import type { RuleCandidate } from "./types.js"; +export type { RuleDiscoveryCache } from "./finder-cache.js"; +export { createRuleDiscoveryCache } from "./finder-cache.js"; +export interface FinderOptions { + /** Project root absolute path (use findProjectRoot to get this). */ + projectRoot: string | null; + /** Target file path (used for distance calculation in dynamic injection mode). null for static mode. */ + targetFile: string | null; + /** User home directory (default: os.homedir()). Injectable for tests. */ + homeDir?: string; + /** Set of disabled sources to omit from discovery. Empty by default. */ + disabledSources?: ReadonlySet; + /** Whether to skip user-home rules. Default: false. */ + skipUserHome?: boolean; + /** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */ + pluginRoot?: string; + platform?: NodeJS.Platform; + cache?: RuleDiscoveryCache; +} +interface PluginBundledFinderOptions { + readonly disabledSources?: ReadonlySet; + readonly cache?: RuleDiscoveryCache; + readonly pluginRoot?: string; + readonly platform?: NodeJS.Platform; +} +export declare function findRuleCandidates(options: FinderOptions): RuleCandidate[]; +export declare function findPluginBundledCandidates(options?: PluginBundledFinderOptions): RuleCandidate[]; diff --git a/plugins/omo/components/rules/dist/rules/finder.js b/plugins/omo/components/rules/dist/rules/finder.js new file mode 100644 index 0000000..7b1a768 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder.js @@ -0,0 +1,146 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { BUNDLED_RULE_SUBDIR, GLOBAL_DISTANCE, PROJECT_RULE_SUBDIRS, PROJECT_SINGLE_FILES, USER_HOME_RULE_SUBDIRS, USER_HOME_SINGLE_FILES, } from "./constants.js"; +import { scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js"; +import { getWalkDirectories, toRelativePath } from "./finder-paths.js"; +import { toProjectRuleSource, toProjectSingleFileSource, toUserHomeRuleSource, toUserHomeSingleFileSource, } from "./finder-sources.js"; +import { resolvePluginRulesRoot } from "./plugin-root.js"; +export { createRuleDiscoveryCache } from "./finder-cache.js"; +const WINDOWS_GIT_BASH_BUNDLED_RULE_PATH = "bundled-rules/windows-git-bash.md"; +export function findRuleCandidates(options) { + const skipUserHome = options.skipUserHome ?? false; + const disabledSources = options.disabledSources ?? new Set(); + const candidates = []; + const homeDirectory = resolve(options.homeDir ?? homedir()); + if (options.projectRoot !== null) { + candidates.push(...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache)); + } + const pluginBundledOptions = { + disabledSources, + ...(options.cache === undefined ? {} : { cache: options.cache }), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + }; + candidates.push(...findPluginBundledCandidates(pluginBundledOptions)); + if (!skipUserHome) { + candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); + } + return candidates; +} +export function findPluginBundledCandidates(options = {}) { + if (options.disabledSources?.has("plugin-bundled") === true) { + return []; + } + const pluginRoot = resolvePluginRulesRoot(options.pluginRoot); + const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR); + const platform = options.platform ?? process.platform; + const candidates = []; + for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) { + const candidate = { + path: scannedFile.path, + realPath: scannedFile.realPath, + source: "plugin-bundled", + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: false, + relativePath: toRelativePath(pluginRoot, scannedFile.path), + }; + if (isPluginBundledCandidateEnabled(candidate, platform)) { + candidates.push(candidate); + } + } + return candidates; +} +function isPluginBundledCandidateEnabled(candidate, platform) { + return candidate.relativePath !== WINDOWS_GIT_BASH_BUNDLED_RULE_PATH || platform === "win32"; +} +function findProjectCandidates(projectRoot, targetFile, disabledSources, cache) { + const rootDirectory = resolve(projectRoot); + const walkDirectories = getWalkDirectories(rootDirectory, targetFile); + const candidates = []; + for (const walkDirectory of walkDirectories) { + for (const [parentDirectory, subDirectory] of PROJECT_RULE_SUBDIRS) { + const source = toProjectRuleSource(parentDirectory, subDirectory); + if (disabledSources.has(source)) { + continue; + } + const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory); + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { + candidates.push({ + path: scannedFile.path, + realPath: scannedFile.realPath, + source, + distance: targetFile === null ? 0 : walkDirectory.distance, + isGlobal: false, + isSingleFile: false, + relativePath: toRelativePath(rootDirectory, scannedFile.path), + }); + } + } + } + for (const walkDirectory of walkDirectories) { + for (const ruleFile of PROJECT_SINGLE_FILES) { + const source = toProjectSingleFileSource(ruleFile); + if (disabledSources.has(source)) { + continue; + } + const filePath = join(walkDirectory.directory, ruleFile); + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { + continue; + } + candidates.push({ + path: fileInfo.path, + realPath: fileInfo.realPath, + source, + distance: targetFile === null ? 0 : walkDirectory.distance, + isGlobal: false, + isSingleFile: true, + relativePath: toRelativePath(rootDirectory, filePath), + }); + } + } + return candidates; +} +function findUserHomeCandidates(homeDirectory, disabledSources, cache) { + const candidates = []; + for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) { + const source = toUserHomeRuleSource(ruleSubdir); + if (disabledSources.has(source)) { + continue; + } + const ruleDirectory = join(homeDirectory, ruleSubdir); + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { + candidates.push({ + path: scannedFile.path, + realPath: scannedFile.realPath, + source, + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: false, + relativePath: toRelativePath(homeDirectory, scannedFile.path), + }); + } + } + for (const ruleFile of USER_HOME_SINGLE_FILES) { + const source = toUserHomeSingleFileSource(ruleFile); + if (disabledSources.has(source)) { + continue; + } + const filePath = join(homeDirectory, ruleFile); + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { + continue; + } + candidates.push({ + path: fileInfo.path, + realPath: fileInfo.realPath, + source, + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: true, + relativePath: toRelativePath(homeDirectory, filePath), + }); + } + return candidates; +} diff --git a/plugins/omo/components/rules/dist/rules/formatter.d.ts b/plugins/omo/components/rules/dist/rules/formatter.d.ts new file mode 100644 index 0000000..b4bd384 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/formatter.d.ts @@ -0,0 +1,7 @@ +import type { LoadedRule } from "./types.js"; +export interface FormatOptions { + maxRuleChars: number; + maxResultChars: number; +} +export declare function formatStaticBlock(rules: ReadonlyArray, options: FormatOptions): string; +export declare function formatDynamicBlock(rules: ReadonlyArray, targetRelativePath: string, options: FormatOptions): string; diff --git a/plugins/omo/components/rules/dist/rules/formatter.js b/plugins/omo/components/rules/dist/rules/formatter.js new file mode 100644 index 0000000..3171fcc --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/formatter.js @@ -0,0 +1,112 @@ +import { truncateBudget, truncateRule } from "./truncator.js"; +function formatRule(rule) { + const body = normalizeRuleBody(rule.body); + if (body.length === 0) { + return `Instructions from: ${rule.path}`; + } + return `Instructions from: ${rule.path}\n\n${body}`; +} +function truncateRules(rules, options) { + const perRuleNormalized = rules.map((rule) => ({ + path: rule.path, + relativePath: rule.relativePath, + body: normalizeRuleBody(rule.body), + source: rule.source, + })); + const perRuleResultChars = Math.floor(options.maxResultChars / Math.max(1, perRuleNormalized.length)); + const perRuleBudgeted = perRuleNormalized.map((rule) => ({ + path: rule.path, + relativePath: rule.relativePath, + body: rule.source === "plugin-bundled" + ? truncateRule(rule.body, { maxChars: perRuleResultChars, relativePath: rule.relativePath }).body + : truncateRule(rule.body, { + maxChars: Math.min(options.maxRuleChars, perRuleResultChars), + relativePath: rule.relativePath, + }).body, + })); + const budgetedRules = truncateBudget({ + rules: perRuleBudgeted.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })), + maxResultChars: options.maxResultChars, + }); + const truncatedRules = []; + for (let index = 0; index < budgetedRules.length; index += 1) { + const sourceRule = perRuleBudgeted[index]; + const budgetedRule = budgetedRules[index]; + if (sourceRule === undefined || budgetedRule === undefined) { + continue; + } + truncatedRules.push({ + path: sourceRule.path, + relativePath: budgetedRule.relativePath, + body: budgetedRule.body, + }); + } + return truncatedRules; +} +export function formatStaticBlock(rules, options) { + if (rules.length === 0) { + return ""; + } + if (options.maxResultChars <= 0) { + return ""; + } + const orderedRules = orderStaticRules(uniqueRulesByBody(rules)); + return ["## Project Instructions", "", truncateRules(orderedRules, options).map(formatRule).join("\n\n")].join("\n"); +} +function orderStaticRules(rules) { + const hephaestusRules = []; + const otherRules = []; + for (const rule of rules) { + if (isHephaestusRule(rule)) { + hephaestusRules.push(rule); + continue; + } + otherRules.push(rule); + } + return [...hephaestusRules, ...otherRules]; +} +function isHephaestusRule(rule) { + return displayFilename(rule).toLowerCase() === "hephaestus.md"; +} +function displayFilename(rule) { + const normalizedPath = rule.relativePath.length > 0 ? rule.relativePath : rule.path; + const segments = normalizedPath + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0); + return segments.at(-1) ?? normalizedPath; +} +function uniqueRulesByBody(rules) { + const uniqueRules = []; + const seenBodies = new Set(); + const userDescriptions = new Set(); + for (const rule of rules) { + const descriptionKey = rule.frontmatter.description?.trim(); + if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) { + continue; + } + const bodyKey = normalizeRuleBody(rule.body); + if (seenBodies.has(bodyKey)) { + continue; + } + seenBodies.add(bodyKey); + if (descriptionKey !== undefined && rule.source !== "plugin-bundled") { + userDescriptions.add(descriptionKey); + } + uniqueRules.push(rule); + } + return uniqueRules; +} +export function formatDynamicBlock(rules, targetRelativePath, options) { + if (rules.length === 0) { + return ""; + } + return [ + `Additional project instructions matched for ${targetRelativePath}:`, + "", + truncateRules(rules, options).map(formatRule).join("\n\n"), + ].join("\n"); +} +function normalizeRuleBody(body) { + return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} diff --git a/plugins/omo/components/rules/dist/rules/matcher.d.ts b/plugins/omo/components/rules/dist/rules/matcher.d.ts new file mode 100644 index 0000000..1aee611 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/matcher.d.ts @@ -0,0 +1,18 @@ +import type { MatchReason, RuleFrontmatter } from "./types.js"; +export interface MatcherInput { + frontmatter: RuleFrontmatter; + isSingleFile: boolean; + /** Path bases to try matching against (POSIX-normalized). */ + pathBases: { + projectRelative: string; + scopeRelative?: string; + basename: string; + }; +} +export interface MatchResult { + matched: boolean; + reason: MatchReason; +} +export declare function matchRule(input: MatcherInput): MatchResult; +export declare function normalizeGlobs(frontmatter: RuleFrontmatter): string[]; +export declare function hashContent(body: string): string; diff --git a/plugins/omo/components/rules/dist/rules/matcher.js b/plugins/omo/components/rules/dist/rules/matcher.js new file mode 100644 index 0000000..e9c4438 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/matcher.js @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import picomatch from "picomatch"; +const compiledPatternSets = new Map(); +export function matchRule(input) { + if (input.isSingleFile) { + return { matched: true, reason: "single-file" }; + } + if (input.frontmatter.alwaysApply === true) { + return { matched: true, reason: "alwaysApply" }; + } + const patterns = normalizeGlobs(input.frontmatter); + if (patterns.length === 0) { + return noMatch(); + } + const pathBases = normalizedPathBases(input.pathBases); + const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns); + for (const { pattern, isMatch } of positivePatterns) { + for (const pathBase of pathBases) { + if (!isMatch(pathBase)) { + continue; + } + if (isExcluded(pathBase, negativeMatchers)) { + return noMatch(); + } + return { matched: true, reason: { kind: "glob", pattern } }; + } + } + return noMatch(); +} +export function normalizeGlobs(frontmatter) { + const patterns = [ + ...normalizePatternList(frontmatter.globs), + ...normalizePatternList(frontmatter.paths), + ...normalizePatternList(frontmatter.applyTo), + ]; + return [...new Set(patterns.map(normalizePath))]; +} +export function hashContent(body) { + return createHash("sha256").update(body).digest("hex"); +} +function normalizePatternList(patterns) { + if (patterns === undefined) { + return []; + } + return Array.isArray(patterns) ? patterns : [patterns]; +} +function normalizePath(path) { + return path.replaceAll("\\", "/"); +} +function normalizedPathBases(pathBases) { + const normalizedBases = [normalizePath(pathBases.projectRelative)]; + if (pathBases.scopeRelative !== undefined) { + normalizedBases.push(normalizePath(pathBases.scopeRelative)); + } + normalizedBases.push(normalizePath(pathBases.basename)); + return normalizedBases; +} +function compiledPatternSetFor(patterns) { + const cacheKey = JSON.stringify(patterns); + const cached = compiledPatternSets.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const compiled = compilePatternSet(patterns); + compiledPatternSets.set(cacheKey, compiled); + return compiled; +} +function compilePatternSet(patterns) { + const positivePatterns = []; + const negativeMatchers = []; + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + negativeMatchers.push(createGlobMatcher(pattern.slice(1))); + continue; + } + positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) }); + } + return { positivePatterns, negativeMatchers }; +} +function createGlobMatcher(pattern) { + return picomatch(normalizePath(pattern), { bash: true, dot: true }); +} +function isExcluded(pathBase, negativeMatchers) { + for (const isMatch of negativeMatchers) { + if (isMatch(pathBase)) { + return true; + } + } + return false; +} +function noMatch() { + return { matched: false, reason: { kind: "no-match" } }; +} diff --git a/plugins/omo/components/rules/dist/rules/ordering.d.ts b/plugins/omo/components/rules/dist/rules/ordering.d.ts new file mode 100644 index 0000000..f30d972 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/ordering.d.ts @@ -0,0 +1,3 @@ +import type { RuleCandidate } from "./types.js"; +export declare function sortCandidates(candidates: ReadonlyArray): T[]; +export declare function compareCandidates(a: RuleCandidate, b: RuleCandidate): number; diff --git a/plugins/omo/components/rules/dist/rules/ordering.js b/plugins/omo/components/rules/dist/rules/ordering.js new file mode 100644 index 0000000..5308e48 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/ordering.js @@ -0,0 +1,27 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +export function sortCandidates(candidates) { + return candidates + .map((candidate, index) => ({ candidate, index })) + .sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index) + .map(({ candidate }) => candidate); +} +export function compareCandidates(a, b) { + return (compareBoolean(a.isGlobal, b.isGlobal) || + compareNumber(a.distance, b.distance) || + compareNumber(SOURCE_PRIORITY.get(a.source) ?? Infinity, SOURCE_PRIORITY.get(b.source) ?? Infinity) || + compareString(a.relativePath, b.relativePath) || + compareString(a.realPath, b.realPath)); +} +function compareBoolean(a, b) { + return Number(a) - Number(b); +} +function compareNumber(a, b) { + return a - b; +} +function compareString(a, b) { + if (a < b) + return -1; + if (a > b) + return 1; + return 0; +} diff --git a/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts b/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts new file mode 100644 index 0000000..1f08b8c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts @@ -0,0 +1,7 @@ +export type ClosingDelimiter = { + readonly start: number; + readonly bodyStart: number; +}; +export declare function stripBom(content: string): string; +export declare function getOpeningDelimiterLength(content: string): number; +export declare function findClosingDelimiter(content: string, openingLength: number): ClosingDelimiter | null; diff --git a/plugins/omo/components/rules/dist/rules/parser-frontmatter.js b/plugins/omo/components/rules/dist/rules/parser-frontmatter.js new file mode 100644 index 0000000..f3bc671 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-frontmatter.js @@ -0,0 +1,30 @@ +const FRONTMATTER_OPENING = "---\n"; +const FRONTMATTER_OPENING_CRLF = "---\r\n"; +export function stripBom(content) { + return content.startsWith("\uFEFF") ? content.slice(1) : content; +} +export function getOpeningDelimiterLength(content) { + if (content.startsWith(FRONTMATTER_OPENING_CRLF)) + return FRONTMATTER_OPENING_CRLF.length; + if (content.startsWith(FRONTMATTER_OPENING)) + return FRONTMATTER_OPENING.length; + return 0; +} +export function findClosingDelimiter(content, openingLength) { + let lineStart = openingLength; + while (lineStart <= content.length) { + const nextNewline = content.indexOf("\n", lineStart); + const lineEnd = nextNewline === -1 ? content.length : nextNewline; + const line = content.slice(lineStart, lineEnd).replace(/\r$/, ""); + if (line === "---") { + return { + start: lineStart, + bodyStart: nextNewline === -1 ? content.length : nextNewline + 1, + }; + } + if (nextNewline === -1) + break; + lineStart = nextNewline + 1; + } + return null; +} diff --git a/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts b/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts new file mode 100644 index 0000000..3c65d9a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts @@ -0,0 +1,2 @@ +import type { RuleFrontmatter } from "./types.js"; +export declare function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter; diff --git a/plugins/omo/components/rules/dist/rules/parser-yaml.js b/plugins/omo/components/rules/dist/rules/parser-yaml.js new file mode 100644 index 0000000..01150ac --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-yaml.js @@ -0,0 +1,237 @@ +import { RuleFrontmatterParseError } from "./errors.js"; +export function parseYamlFrontmatter(yamlContent) { + const lines = yamlContent.replace(/\r\n/g, "\n").split("\n"); + const frontmatter = {}; + const globValues = []; + const seenGlobs = new Set(); + let lineIndex = 0; + while (lineIndex < lines.length) { + const rawLine = lines[lineIndex]; + if (rawLine === undefined) + break; + const line = stripComment(rawLine).trim(); + if (line.length === 0) { + lineIndex += 1; + continue; + } + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { + throw new RuleFrontmatterParseError(`Expected key-value pair on line ${lineIndex + 1}`); + } + const key = line.slice(0, colonIndex).trim(); + const rawValue = line.slice(colonIndex + 1).trim(); + if (key === "description") { + frontmatter.description = parseStringValue(rawValue); + lineIndex += 1; + continue; + } + if (key === "alwaysApply") { + frontmatter.alwaysApply = parseBooleanValue(rawValue, lineIndex + 1); + lineIndex += 1; + continue; + } + if (key === "globs" || key === "paths" || key === "applyTo") { + const parsed = parseGlobValue(rawValue, lines, lineIndex); + for (const glob of parsed.values) { + if (!seenGlobs.has(glob)) { + seenGlobs.add(glob); + globValues.push(glob); + } + } + lineIndex += parsed.consumed; + continue; + } + lineIndex += 1; + } + const singleGlob = globValues[0]; + if (globValues.length === 1 && singleGlob !== undefined) { + frontmatter.globs = singleGlob; + } + else if (globValues.length > 1) { + frontmatter.globs = globValues; + } + return frontmatter; +} +function parseBooleanValue(value, lineNumber) { + if (value === "true") + return true; + if (value === "false") + return false; + throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`); +} +function parseGlobValue(rawValue, lines, lineIndex) { + if (rawValue.startsWith("[")) { + return { values: parseInlineArray(rawValue), consumed: 1 }; + } + if (rawValue.length === 0) { + return parseMultilineArray(lines, lineIndex); + } + const quotedScalar = isQuotedScalar(rawValue); + const value = parseStringValue(rawValue); + if (!quotedScalar && value.includes(",")) { + return { + values: value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + consumed: 1, + }; + } + return { values: [value], consumed: 1 }; +} +function isQuotedScalar(value) { + return value.startsWith('"') || value.startsWith("'"); +} +function parseMultilineArray(lines, lineIndex) { + const values = []; + let consumed = 1; + for (let index = lineIndex + 1; index < lines.length; index += 1) { + const rawLine = lines[index]; + if (rawLine === undefined) + break; + const lineWithoutComment = stripComment(rawLine); + if (lineWithoutComment.trim().length === 0) { + consumed += 1; + continue; + } + const arrayItem = lineWithoutComment.match(/^\s+-\s*(.*)$/); + if (arrayItem === null) + break; + values.push(parseStringValue(arrayItem[1] ?? "")); + consumed += 1; + } + return { values: values.filter(Boolean), consumed }; +} +function parseInlineArray(value) { + const closingBracketIndex = findClosingBracket(value); + if (closingBracketIndex === -1) { + throw new RuleFrontmatterParseError("Unclosed inline array"); + } + const trailing = value.slice(closingBracketIndex + 1).trim(); + if (trailing.length > 0) { + throw new RuleFrontmatterParseError("Unexpected content after inline array"); + } + const content = value.slice(1, closingBracketIndex).trim(); + if (content.length === 0) + return []; + return splitCommaSeparated(content).map(parseStringValue).filter(Boolean); +} +function findClosingBracket(value) { + let quote = null; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === undefined) + continue; + if (escaped) { + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + continue; + } + if (quote === null && character === "]") + return index; + } + return -1; +} +function splitCommaSeparated(value) { + const values = []; + let current = ""; + let quote = null; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === undefined) + continue; + if (escaped) { + current += character; + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + current += character; + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + current += character; + continue; + } + if (quote === null && character === ",") { + values.push(current.trim()); + current = ""; + continue; + } + current += character; + } + if (quote !== null) { + throw new RuleFrontmatterParseError("Unclosed quoted value"); + } + values.push(current.trim()); + return values.filter(Boolean); +} +function parseStringValue(value) { + if (value.length === 0) + return ""; + if (value.startsWith('"')) + return parseJsonString(value); + if (value.startsWith("'") && value.endsWith("'")) + return value.slice(1, -1); + if (value.startsWith("'")) + throw new RuleFrontmatterParseError("Unclosed quoted value"); + return value; +} +function parseJsonString(value) { + try { + const parsedValue = JSON.parse(value); + if (typeof parsedValue !== "string") { + throw new RuleFrontmatterParseError("Expected JSON-quoted string"); + } + return parsedValue; + } + catch (error) { + if (error instanceof RuleFrontmatterParseError) + throw error; + throw new RuleFrontmatterParseError("Invalid JSON-quoted string"); + } +} +function stripComment(line) { + let quote = null; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === undefined) + continue; + if (escaped) { + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + continue; + } + if (quote === null && character === "#") + return line.slice(0, index); + } + return line; +} diff --git a/plugins/omo/components/rules/dist/rules/parser.d.ts b/plugins/omo/components/rules/dist/rules/parser.d.ts new file mode 100644 index 0000000..cb945a5 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser.d.ts @@ -0,0 +1,3 @@ +import type { ParsedRule } from "./types.js"; +/** Parse markdown rule content and extract the supported YAML frontmatter subset. */ +export declare function parseRule(content: string): ParsedRule; diff --git a/plugins/omo/components/rules/dist/rules/parser.js b/plugins/omo/components/rules/dist/rules/parser.js new file mode 100644 index 0000000..3a1e24b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser.js @@ -0,0 +1,31 @@ +import { findClosingDelimiter, getOpeningDelimiterLength, stripBom } from "./parser-frontmatter.js"; +import { parseYamlFrontmatter } from "./parser-yaml.js"; +/** Parse markdown rule content and extract the supported YAML frontmatter subset. */ +export function parseRule(content) { + const normalizedContent = stripBom(content); + const openingLength = getOpeningDelimiterLength(normalizedContent); + if (openingLength === 0) { + return { frontmatter: {}, body: normalizedContent }; + } + const closingDelimiter = findClosingDelimiter(normalizedContent, openingLength); + if (closingDelimiter === null) { + return { + frontmatter: {}, + body: normalizedContent, + diagnostic: "Missing closing frontmatter delimiter", + }; + } + const yamlContent = normalizedContent.slice(openingLength, closingDelimiter.start); + const body = normalizedContent.slice(closingDelimiter.bodyStart); + try { + return { frontmatter: parseYamlFrontmatter(yamlContent), body }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Invalid YAML frontmatter"; + return { + frontmatter: {}, + body: normalizedContent, + diagnostic: `Malformed frontmatter: ${message}`, + }; + } +} diff --git a/plugins/omo/components/rules/dist/rules/plugin-root.d.ts b/plugins/omo/components/rules/dist/rules/plugin-root.d.ts new file mode 100644 index 0000000..7a83c2f --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/plugin-root.d.ts @@ -0,0 +1 @@ +export declare function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl?: string): string; diff --git a/plugins/omo/components/rules/dist/rules/plugin-root.js b/plugins/omo/components/rules/dist/rules/plugin-root.js new file mode 100644 index 0000000..4b15e32 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/plugin-root.js @@ -0,0 +1,48 @@ +import { statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json"); +export function resolvePluginRulesRoot(pluginRoot, moduleUrl = import.meta.url) { + const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"]; + if (configuredRoot !== undefined && configuredRoot.trim().length > 0) { + return resolveRulesComponentRoot(resolve(configuredRoot)); + } + const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl))); + if (discoveredRoot !== null) { + return resolveRulesComponentRoot(discoveredRoot); + } + return fileURLToPath(new URL("../../..", moduleUrl)); +} +function findNearestPluginRoot(startDirectory) { + let currentDirectory = resolve(startDirectory); + while (true) { + if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) { + return currentDirectory; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } +} +function resolveRulesComponentRoot(pluginRoot) { + const componentRoot = join(pluginRoot, "components", "rules"); + return isDirectory(componentRoot) ? componentRoot : pluginRoot; +} +function isFile(path) { + try { + return statSync(path).isFile(); + } + catch { + return false; + } +} +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } + catch { + return false; + } +} diff --git a/plugins/omo/components/rules/dist/rules/project-root.d.ts b/plugins/omo/components/rules/dist/rules/project-root.d.ts new file mode 100644 index 0000000..079a718 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/project-root.d.ts @@ -0,0 +1 @@ +export declare function findProjectRoot(startPath: string, markers?: ReadonlyArray): string | null; diff --git a/plugins/omo/components/rules/dist/rules/project-root.js b/plugins/omo/components/rules/dist/rules/project-root.js new file mode 100644 index 0000000..d9b4b5d --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/project-root.js @@ -0,0 +1,23 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { PROJECT_MARKERS } from "./constants.js"; +export function findProjectRoot(startPath, markers = PROJECT_MARKERS) { + const resolvedStartPath = resolve(startPath); + if (!existsSync(resolvedStartPath)) { + return null; + } + const startStats = statSync(resolvedStartPath); + let currentDirectory = startStats.isDirectory() ? resolvedStartPath : dirname(resolvedStartPath); + const filesystemRoot = resolve("/"); + while (true) { + for (const marker of markers) { + if (existsSync(join(currentDirectory, marker))) { + return currentDirectory; + } + } + if (currentDirectory === filesystemRoot) { + return null; + } + currentDirectory = dirname(currentDirectory); + } +} diff --git a/plugins/omo/components/rules/dist/rules/scanner.d.ts b/plugins/omo/components/rules/dist/rules/scanner.d.ts new file mode 100644 index 0000000..7a66748 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/scanner.d.ts @@ -0,0 +1,14 @@ +export interface ScanOptions { + rootDir: string; + excludedDirs?: ReadonlyArray; + /** Maximum recursion depth. Default: 10 */ + maxDepth?: number; + maxFiles?: number; +} +export interface ScannedFile { + /** Absolute path as encountered (may be a symlink). */ + path: string; + /** Real (resolved) path; same as path if not a symlink. */ + realPath: string; +} +export declare function scanRuleFiles(options: ScanOptions): ScannedFile[]; diff --git a/plugins/omo/components/rules/dist/rules/scanner.js b/plugins/omo/components/rules/dist/rules/scanner.js new file mode 100644 index 0000000..6d35993 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/scanner.js @@ -0,0 +1,111 @@ +import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; +export function scanRuleFiles(options) { + const rootPath = toAbsolutePath(options.rootDir); + if (!existsSync(rootPath)) { + return []; + } + let rootStats; + try { + rootStats = statSync(rootPath); + } + catch { + return []; + } + if (!rootStats.isDirectory()) { + return []; + } + const results = []; + const visitedDirectories = new Set(); + const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS); + const maxDepth = options.maxDepth ?? 10; + const maxFiles = normalizeMaxFiles(options.maxFiles); + scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + return results; +} +function normalizeMaxFiles(maxFiles) { + const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES; + if (!Number.isFinite(value) || value < 0) + return DEFAULT_MAX_SCAN_FILES; + return Math.floor(value); +} +function toAbsolutePath(filePath) { + return isAbsolute(filePath) ? filePath : resolve(filePath); +} +function scanDirectory(directoryPath, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } + let realDirectoryPath; + try { + realDirectoryPath = realpathSync.native(directoryPath); + } + catch { + return; + } + if (visitedDirectories.has(realDirectoryPath)) { + return; + } + visitedDirectories.add(realDirectoryPath); + let entries; + try { + entries = readdirSync(directoryPath, { withFileTypes: true }).sort((leftEntry, rightEntry) => leftEntry.name.localeCompare(rightEntry.name)); + } + catch { + return; + } + for (const entry of entries) { + if (results.length >= maxFiles) { + return; + } + const entryPath = join(directoryPath, entry.name); + if (entry.isDirectory()) { + if (!excludedDirs.has(entry.name) && depth < maxDepth) { + scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + } + continue; + } + if (entry.isSymbolicLink()) { + scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + continue; + } + if (entry.isFile() && isRuleFile(entry.name)) { + results.push({ path: entryPath, realPath: resolveRealPath(entryPath) }); + } + } +} +function scanSymbolicLink(linkPath, linkName, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } + let targetStats; + try { + targetStats = statSync(linkPath); + } + catch { + return; + } + if (targetStats.isDirectory()) { + if (!excludedDirs.has(linkName) && depth < maxDepth) { + scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + } + return; + } + if (targetStats.isFile() && isRuleFile(linkName)) { + results.push({ path: linkPath, realPath: resolveRealPath(linkPath) }); + } +} +function isRuleFile(fileName) { + return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension)); +} +function resolveRealPath(filePath) { + try { + const realPath = realpathSync.native(filePath); + const fileStats = lstatSync(filePath); + return fileStats.isSymbolicLink() ? realPath : filePath; + } + catch { + return filePath; + } +} diff --git a/plugins/omo/components/rules/dist/rules/sources.d.ts b/plugins/omo/components/rules/dist/rules/sources.d.ts new file mode 100644 index 0000000..58e1226 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/sources.d.ts @@ -0,0 +1,3 @@ +import type { PiRulesConfig } from "./types.js"; +export declare const DEFAULT_AUTO_DISABLED_SOURCES: readonly string[]; +export declare function disabledSourcesFromConfig(config: PiRulesConfig): ReadonlySet | undefined; diff --git a/plugins/omo/components/rules/dist/rules/sources.js b/plugins/omo/components/rules/dist/rules/sources.js new file mode 100644 index 0000000..489e22a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/sources.js @@ -0,0 +1,9 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +export const DEFAULT_AUTO_DISABLED_SOURCES = ["AGENTS.md", "~/.claude/rules", "~/.claude/CLAUDE.md"]; +export function disabledSourcesFromConfig(config) { + if (config.enabledSources === "auto") { + return new Set(DEFAULT_AUTO_DISABLED_SOURCES); + } + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} diff --git a/plugins/omo/components/rules/dist/rules/truncator.d.ts b/plugins/omo/components/rules/dist/rules/truncator.d.ts new file mode 100644 index 0000000..86a2634 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/truncator.d.ts @@ -0,0 +1,17 @@ +import type { TruncationResult } from "./types.js"; +type BudgetRule = { + body: string; + relativePath: string; +}; +type BudgetResult = BudgetRule & { + truncated: boolean; +}; +export declare function truncateRule(body: string, options: { + maxChars: number; + relativePath: string; +}): TruncationResult; +export declare function truncateBudget(input: { + rules: ReadonlyArray; + maxResultChars: number; +}): BudgetResult[]; +export {}; diff --git a/plugins/omo/components/rules/dist/rules/truncator.js b/plugins/omo/components/rules/dist/rules/truncator.js new file mode 100644 index 0000000..7ee0f78 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/truncator.js @@ -0,0 +1,45 @@ +import { TRUNCATION_NOTICE } from "./constants.js"; +function truncationNotice(relativePath) { + return TRUNCATION_NOTICE.replace("{path}", relativePath); +} +function safeSliceEnd(body, end) { + if (end <= 0) { + return 0; + } + const lastCodeUnit = body.charCodeAt(end - 1); + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { + return end - 1; + } + return end; +} +export function truncateRule(body, options) { + if (body.length <= options.maxChars) { + return { body, truncated: false, originalLength: body.length }; + } + const notice = truncationNotice(options.relativePath); + if (options.maxChars < notice.length) { + return { body: notice, truncated: true, originalLength: body.length }; + } + const sliceEnd = safeSliceEnd(body, options.maxChars - notice.length); + return { body: `${body.slice(0, sliceEnd)}${notice}`, truncated: true, originalLength: body.length }; +} +export function truncateBudget(input) { + const results = []; + let remainingBudget = input.maxResultChars; + for (const rule of input.rules) { + if (remainingBudget >= rule.body.length) { + results.push({ body: rule.body, truncated: false, relativePath: rule.relativePath }); + remainingBudget -= rule.body.length; + continue; + } + const notice = truncationNotice(rule.relativePath); + if (remainingBudget <= notice.length) { + break; + } + const sliceEnd = safeSliceEnd(rule.body, remainingBudget - notice.length); + const body = `${rule.body.slice(0, sliceEnd)}${notice}`; + results.push({ body, truncated: true, relativePath: rule.relativePath }); + remainingBudget -= body.length; + } + return results; +} diff --git a/plugins/omo/components/rules/dist/rules/types.d.ts b/plugins/omo/components/rules/dist/rules/types.d.ts new file mode 100644 index 0000000..d7b2528 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/types.d.ts @@ -0,0 +1,122 @@ +/** + * Public types for pi-rules. + * + * These types are stable contracts between modules. The frontmatter type + * mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`) + * aliases that are normalized into `globs` internally. + */ +/** + * YAML frontmatter parsed from a rule markdown file. + * `paths` (Claude alias) and `applyTo` (Copilot alias) are normalized into + * `globs` by the parser before any matcher sees this struct. + */ +export interface RuleFrontmatter { + description?: string; + globs?: string | string[]; + paths?: string | string[]; + applyTo?: string | string[]; + alwaysApply?: boolean; +} +/** + * Result of parsing a rule markdown file. + * `body` excludes the frontmatter delimiters and the YAML payload. + */ +export interface ParsedRule { + frontmatter: RuleFrontmatter; + body: string; + /** + * Diagnostic message if frontmatter parsing failed but the body was salvaged. + * Empty when parsing succeeded. + */ + diagnostic?: string; +} +/** + * A discovered rule file candidate before parsing/matching. + * + * `path` is the absolute path as discovered (possibly via symlink). + * `realPath` is the canonical resolved path used for dedup. + * `source` identifies which discovery source produced this candidate. + */ +export interface RuleCandidate { + path: string; + realPath: string; + source: RuleSource; + /** + * Distance from the target file directory to the directory containing this rule. + * 0 = same directory, 9999 = global/user-home rule. + */ + distance: number; + isGlobal: boolean; + /** + * True when this candidate is a SINGLE-FILE rule like + * `.github/copilot-instructions.md` (frontmatter optional, applies always). + */ + isSingleFile: boolean; + /** + * Path relative to project root, POSIX-normalized. Used for matcher and display. + * Empty string for user-home global rules. + */ + relativePath: string; +} +/** + * A fully-loaded rule ready for injection. + */ +export interface LoadedRule extends RuleCandidate { + frontmatter: RuleFrontmatter; + body: string; + contentHash: string; + matchReason: MatchReason; +} +/** + * Source identifier for rule files. Used for deterministic ordering and display. + */ +export type RuleSource = ".omo/rules" | ".claude/rules" | ".cursor/rules" | ".github/instructions" | ".github/copilot-instructions.md" | "CONTEXT.md" | "plugin-bundled" | "~/.omo/rules" | "~/.opencode/rules" | "~/.claude/rules"; +/** + * Why a candidate matched the target file. Surfaced in the injection block so + * the model can attribute its behavior to a specific rule. + */ +export type MatchReason = "alwaysApply" | "single-file" | { + kind: "glob"; + pattern: string; +} | { + kind: "no-match"; +}; +/** + * Truncation result. + */ +export interface TruncationResult { + body: string; + truncated: boolean; + originalLength: number; +} +/** + * Configuration knobs resolved from env vars and package.json. + */ +export interface PiRulesConfig { + disabled: boolean; + mode: "static" | "dynamic" | "both" | "off"; + maxRuleChars: number; + maxResultChars: number; + postCompactMaxRuleChars: number; + postCompactMaxResultChars: number; + enabledSources: RuleSource[] | "auto"; +} +/** + * Per-session in-memory dedup state. + * + * `staticDedup` keys are `{cwd}::{rulePath}::{contentHash}` strings. + * `dynamicDedup` stores session-scoped `{rulePath}::{contentHash}` strings. + */ +export interface SessionState { + cwd: string | undefined; + staticDedup: Set; + dynamicDedup: Map>; + dynamicTargetFingerprints: Map; + loadedRules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +} +export interface RuleDiagnostic { + severity: "warning" | "error"; + source: string; + message: string; +} diff --git a/plugins/omo/components/rules/dist/rules/types.js b/plugins/omo/components/rules/dist/rules/types.js new file mode 100644 index 0000000..4244d3b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/types.js @@ -0,0 +1,8 @@ +/** + * Public types for pi-rules. + * + * These types are stable contracts between modules. The frontmatter type + * mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`) + * aliases that are normalized into `globs` internally. + */ +export {}; diff --git a/plugins/omo/components/rules/dist/session-state-lock.d.ts b/plugins/omo/components/rules/dist/session-state-lock.d.ts new file mode 100644 index 0000000..02113cd --- /dev/null +++ b/plugins/omo/components/rules/dist/session-state-lock.d.ts @@ -0,0 +1,3 @@ +export declare const SESSION_STATE_LOCK_CONTENDED: unique symbol; +export type SessionStateLockResult = T | typeof SESSION_STATE_LOCK_CONTENDED; +export declare function withSessionStateLock(cachePath: string, callback: () => T): SessionStateLockResult; diff --git a/plugins/omo/components/rules/dist/session-state-lock.js b/plugins/omo/components/rules/dist/session-state-lock.js new file mode 100644 index 0000000..e8fe879 --- /dev/null +++ b/plugins/omo/components/rules/dist/session-state-lock.js @@ -0,0 +1,41 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; +export const SESSION_STATE_LOCK_CONTENDED = Symbol("session-state-lock-contended"); +const LOCK_RETRY_COUNT = 20; +const LOCK_RETRY_DELAY_MS = 5; +const LOCK_SLEEP_VIEW = new Int32Array(new SharedArrayBuffer(4)); +export function withSessionStateLock(cachePath, callback) { + const lockPath = `${cachePath}.lock`; + mkdirSync(dirname(cachePath), { recursive: true }); + for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt += 1) { + try { + mkdirSync(lockPath); + try { + return callback(); + } + finally { + rmSync(lockPath, { recursive: true, force: true }); + } + } + catch (error) { + if (errorCode(error) === "EEXIST") { + sleepSync(LOCK_RETRY_DELAY_MS); + continue; + } + throw error; + } + } + return SESSION_STATE_LOCK_CONTENDED; +} +function errorCode(error) { + if (!isRecord(error)) { + return undefined; + } + return Reflect.get(error, "code"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function sleepSync(milliseconds) { + Atomics.wait(LOCK_SLEEP_VIEW, 0, 0, milliseconds); +} diff --git a/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts b/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts new file mode 100644 index 0000000..4a6b65c --- /dev/null +++ b/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts @@ -0,0 +1,5 @@ +type RuntimeEnv = Readonly>; +export declare const SPARKSHELL_AWARENESS_DEDUP_KEY = "__omo_sparkshell_awareness__"; +export declare function isCodexAppServerActive(env?: RuntimeEnv): boolean; +export declare function getSparkShellRuntimeAwareness(env?: RuntimeEnv): string; +export {}; diff --git a/plugins/omo/components/rules/dist/sparkshell-awareness.js b/plugins/omo/components/rules/dist/sparkshell-awareness.js new file mode 100644 index 0000000..c547da7 --- /dev/null +++ b/plugins/omo/components/rules/dist/sparkshell-awareness.js @@ -0,0 +1,45 @@ +const SPARKSHELL_AWARENESS_MARKER = "## Sparkshell Runtime"; +export const SPARKSHELL_AWARENESS_DEDUP_KEY = "__omo_sparkshell_awareness__"; +export function isCodexAppServerActive(env = process.env) { + const originator = env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"]?.toLowerCase() ?? ""; + const bundleIdentifier = env["__CFBundleIdentifier"]?.toLowerCase() ?? ""; + const shellActive = isTruthy(env["CODEX_SHELL"]); + return (shellActive && + (originator.includes("codex desktop") || + originator.includes("codex app") || + bundleIdentifier === "com.openai.codex")); +} +function isSparkShellAppServerConfigured(env = process.env) { + const codexSocketPath = env["CODEX_APP_SERVER_SOCKET"]?.trim() ?? ""; + const omoSocketPath = env["OMO_SPARKSHELL_APP_SERVER_SOCKET"]?.trim() ?? ""; + return codexSocketPath.length > 0 || omoSocketPath.length > 0; +} +export function getSparkShellRuntimeAwareness(env = process.env) { + const override = env["OMO_SPARKSHELL_AWARENESS"] ?? env["LAZYCODEX_SPARKSHELL_AWARENESS"]; + if (isFalsy(override)) { + return ""; + } + if (!isTruthy(override) && !isCodexAppServerActive(env) && !isSparkShellAppServerConfigured(env)) { + return ""; + } + return [ + SPARKSHELL_AWARENESS_MARKER, + "", + "- Prefer `omo sparkshell ` for repo inspection, CLI smoke tests, git/history checks, and bounded verification before falling back to raw shell commands.", + "- Use `omo sparkshell --shell ''` only when shell metacharacters are required.", + "- Use `omo sparkshell --tmux-pane --tail-lines 400` to inspect an existing tmux pane. Tail lines must stay between 100 and 1000.", + "- When no native sidecar or appserver is available, Sparkshell silently falls back to raw command execution. `OMO_SPARKSHELL_BIN` selects a native sidecar path.", + ].join("\n"); +} +function isTruthy(value) { + if (value === undefined) { + return false; + } + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} +function isFalsy(value) { + if (value === undefined) { + return false; + } + return ["0", "false", "no", "off"].includes(value.trim().toLowerCase()); +} diff --git a/plugins/omo/components/rules/dist/static-injection.d.ts b/plugins/omo/components/rules/dist/static-injection.d.ts new file mode 100644 index 0000000..32af80e --- /dev/null +++ b/plugins/omo/components/rules/dist/static-injection.d.ts @@ -0,0 +1,3 @@ +import type { CodexRulesHookOptions } from "./codex-hook-options.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +export declare function runStaticInjection(cwd: string, transcriptPath: string | null, eventName: "SessionStart" | "UserPromptSubmit", cachePath: string, options: CodexRulesHookOptions, completedPostCompactChannel?: "static", transcriptSearchOptions?: TranscriptSearchOptions, model?: string): string; diff --git a/plugins/omo/components/rules/dist/static-injection.js b/plugins/omo/components/rules/dist/static-injection.js new file mode 100644 index 0000000..49f3d85 --- /dev/null +++ b/plugins/omo/components/rules/dist/static-injection.js @@ -0,0 +1,45 @@ +import { configFromEnvironment } from "./config.js"; +import { formatAdditionalContextOutput } from "./hook-output.js"; +import { completePostCompactRecovery, hydrateEngineState, persistEngineState } from "./persistent-cache.js"; +import { withPostCompactBudget } from "./post-compact-budget.js"; +import { createRulesEngine } from "./rules-engine-factory.js"; +import { getSparkShellRuntimeAwareness, SPARKSHELL_AWARENESS_DEDUP_KEY } from "./sparkshell-awareness.js"; +import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js"; +export function runStaticInjection(cwd, transcriptPath, eventName, cachePath, options, completedPostCompactChannel, transcriptSearchOptions = {}, model) { + const config = configFromEnvironment(options.env); + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + if (completedPostCompactChannel !== undefined) { + completePostCompactRecovery(cachePath, completedPostCompactChannel); + } + return ""; + } + const effectiveConfig = completedPostCompactChannel === undefined + ? config + : withPostCompactBudget(config, { model: model ?? "", transcriptPath }); + const engine = createRulesEngine(options, effectiveConfig); + hydrateEngineState(engine, cachePath); + engine.state.cwd = cwd; + const loaded = engine.loadStaticRules(cwd); + const rules = filterRulesAlreadyInTranscript(loaded.rules.filter((rule) => !engine.isStaticInjected(rule)), transcriptPath, (rule) => { + engine.markStaticInjected(rule); + }, transcriptSearchOptions); + const sparkshellAwareness = engine.state.staticDedup.has(SPARKSHELL_AWARENESS_DEDUP_KEY) + ? "" + : getSparkShellRuntimeAwareness(options.env); + if (rules.length === 0 && sparkshellAwareness.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactChannel); + return ""; + } + const block = engine.formatStatic(rules); + for (const rule of rules) { + engine.markStaticInjected(rule); + } + if (sparkshellAwareness.length > 0) { + engine.state.staticDedup.add(SPARKSHELL_AWARENESS_DEDUP_KEY); + } + persistEngineState(engine, cachePath, completedPostCompactChannel); + return formatAdditionalContextOutput(eventName, combineStaticContext(block, sparkshellAwareness)); +} +function combineStaticContext(...blocks) { + return blocks.filter((block) => block.trim().length > 0).join("\n\n"); +} diff --git a/plugins/omo/components/rules/dist/tool-paths.d.ts b/plugins/omo/components/rules/dist/tool-paths.d.ts new file mode 100644 index 0000000..c4aa2f5 --- /dev/null +++ b/plugins/omo/components/rules/dist/tool-paths.d.ts @@ -0,0 +1,6 @@ +export interface CodexPostToolUseLike { + tool_name: string; + tool_input: unknown; + tool_response: unknown; +} +export declare function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[]; diff --git a/plugins/omo/components/rules/dist/tool-paths.js b/plugins/omo/components/rules/dist/tool-paths.js new file mode 100644 index 0000000..2cd742e --- /dev/null +++ b/plugins/omo/components/rules/dist/tool-paths.js @@ -0,0 +1,168 @@ +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]); +const TRACKED_TOOL_NAMES = new Set([ + "read", + "read_file", + "mcp__filesystem__read_file", + "mcp__filesystem__read_multiple_files", + "mcp__filesystem__write_file", + "mcp__filesystem__edit_file", + "write", + "edit", + "multiedit", + "multi_edit", + "apply_patch", + "bash", + "shell_command", + "exec_command", +]); +export function extractCodexToolPaths(input, cwd) { + const toolName = input.tool_name.toLowerCase(); + if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) { + return []; + } + const paths = new Set(); + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + addCommonPathFields(paths, toolInput, cwd); + addPatchPayloadPaths(paths, toolInput, cwd); + addPatchRecordPaths(paths, toolInput["files"], cwd); + addPatchRecordPaths(paths, toolInput["changes"], cwd); + if (COMMAND_TOOL_NAMES.has(toolName)) { + const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd"); + const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd"); + addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir)); + } + return [...paths]; +} +function addCommonPathFields(paths, input, cwd) { + for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) { + addPath(paths, input[key], cwd, false); + } + for (const key of ["paths", "filePaths", "file_paths"]) { + addPathArray(paths, input[key], cwd, false); + } +} +function addPatchPayloadPaths(paths, input, cwd) { + for (const key of ["input", "patch", "command", "cmd"]) { + const value = input[key]; + if (typeof value === "string") { + addPatchHeaderPaths(paths, value, cwd); + } + } +} +function addPatchHeaderPaths(paths, patch, cwd) { + for (const line of patch.split("\n")) { + for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) { + if (line.startsWith(prefix)) { + addPath(paths, line.slice(prefix.length).trim(), cwd, false); + } + } + } +} +function addPatchRecordPaths(paths, value, cwd) { + if (!Array.isArray(value)) + return; + for (const item of value) { + if (typeof item === "string") { + addPath(paths, item, cwd, false); + continue; + } + if (!isRecord(item)) + continue; + addCommonPathFields(paths, item, cwd); + for (const key of ["movePath", "move_path", "to", "from"]) { + addPath(paths, item[key], cwd, false); + } + } +} +function addCommandPaths(paths, command, cwd) { + if (command === undefined) + return; + for (const token of tokenizeShell(command)) { + if (token.length === 0 || token.startsWith("-") || token.includes("*")) { + continue; + } + addPath(paths, token, cwd, true); + } +} +function addPathArray(paths, value, cwd, mustExist) { + if (!Array.isArray(value)) + return; + for (const item of value) { + addPath(paths, item, cwd, mustExist); + } +} +function addPath(paths, value, cwd, mustExist) { + if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) { + return; + } + const path = resolvePath(cwd, value); + if (mustExist && !isExistingFile(path)) { + return; + } + paths.add(path); +} +function resolvePath(cwd, filePath) { + return isAbsolute(filePath) ? filePath : resolve(cwd, filePath); +} +function isExistingFile(filePath) { + try { + return existsSync(filePath) && statSync(filePath).isFile(); + } + catch { + return false; + } +} +function looksLikeUrl(value) { + return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value); +} +function stringProperty(value, key) { + const property = value[key]; + return typeof property === "string" && property.length > 0 ? property : undefined; +} +function tokenizeShell(command) { + const tokens = []; + let current = ""; + let quote = null; + let escaped = false; + for (const character of command) { + if (escaped) { + current += character; + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if ((character === "'" || character === '"') && quote === null) { + quote = character; + continue; + } + if (quote === character) { + quote = null; + continue; + } + if (quote === null && /\s/.test(character)) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + current += character; + } + if (current.length > 0) { + tokens.push(current); + } + return tokens; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isFailedToolResponse(value) { + if (!isRecord(value)) + return false; + return (value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"); +} diff --git a/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts b/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts new file mode 100644 index 0000000..a084798 --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts @@ -0,0 +1,3 @@ +import type { LoadedRule } from "./rules/types.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +export declare function filterRulesAlreadyInTranscript(rules: ReadonlyArray, transcriptPath: string | null, markInjected: (rule: LoadedRule) => void, options?: TranscriptSearchOptions): LoadedRule[]; diff --git a/plugins/omo/components/rules/dist/transcript-rule-filter.js b/plugins/omo/components/rules/dist/transcript-rule-filter.js new file mode 100644 index 0000000..52ae293 --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-rule-filter.js @@ -0,0 +1,46 @@ +import { readTranscriptSearchText } from "./transcript-search.js"; +export function filterRulesAlreadyInTranscript(rules, transcriptPath, markInjected, options = {}) { + if (rules.length === 0 || transcriptPath === null) { + return [...rules]; + } + const transcriptText = readTranscriptSearchText(transcriptPath, options); + if (transcriptText === null) { + return [...rules]; + } + const pendingRules = []; + for (const rule of rules) { + if (isRuleAlreadyInTranscript(rule, transcriptText)) { + markInjected(rule); + continue; + } + pendingRules.push(rule); + } + return pendingRules; +} +function isRuleAlreadyInTranscript(rule, transcriptText) { + const staticReferenceNeedles = [ + `- [${displayFilename(rule)}]{${rule.path}}`, + `- [${displayFilename(rule)}]{${rule.realPath}}`, + ]; + if (staticReferenceNeedles.some((needle) => transcriptText.includes(needle))) { + return true; + } + const bodyNeedle = rule.body.trim().slice(0, 2_000); + if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) { + return false; + } + const markers = [ + `Instructions from: ${rule.path}`, + `Instructions from: ${rule.realPath}`, + rule.relativePath.length === 0 ? null : rule.relativePath, + ].filter((marker) => marker !== null); + return markers.some((marker) => transcriptText.includes(marker)); +} +function displayFilename(rule) { + const normalizedPath = rule.relativePath.length > 0 ? rule.relativePath : rule.path; + const segments = normalizedPath + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0); + return segments.at(-1) ?? normalizedPath; +} diff --git a/plugins/omo/components/rules/dist/transcript-search.d.ts b/plugins/omo/components/rules/dist/transcript-search.d.ts new file mode 100644 index 0000000..c88f13c --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-search.d.ts @@ -0,0 +1,4 @@ +export interface TranscriptSearchOptions { + readonly latestCompactedReplacementOnly?: boolean; +} +export declare function readTranscriptSearchText(transcriptPath: string, options?: TranscriptSearchOptions): string | null; diff --git a/plugins/omo/components/rules/dist/transcript-search.js b/plugins/omo/components/rules/dist/transcript-search.js new file mode 100644 index 0000000..379338d --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-search.js @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +export function readTranscriptSearchText(transcriptPath, options = {}) { + try { + const rawTranscript = readFileSync(transcriptPath, "utf8"); + if (options.latestCompactedReplacementOnly === true) { + return latestCompactedReplacementSearchText(rawTranscript); + } + return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n"); + } + catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} +function latestCompactedReplacementSearchText(rawTranscript) { + const lines = rawTranscript.split(/\r?\n/); + let latestCompactedLineIndex = -1; + let replacementHistory = null; + for (const [index, line] of lines.entries()) { + const parsed = parseJsonLine(line); + if (!isRecord(parsed) || parsed["type"] !== "compacted") { + continue; + } + const payload = parsed["payload"]; + if (!isRecord(payload)) { + continue; + } + const candidateReplacementHistory = payload["replacement_history"]; + if (!Array.isArray(candidateReplacementHistory)) { + continue; + } + latestCompactedLineIndex = index; + replacementHistory = candidateReplacementHistory; + } + if (replacementHistory === null) { + return null; + } + const values = []; + collectStrings(replacementHistory, values); + const laterTranscript = lines.slice(latestCompactedLineIndex + 1).join("\n"); + values.push(laterTranscript, ...collectJsonLineStrings(laterTranscript)); + return values.join("\n"); +} +function collectJsonLineStrings(rawTranscript) { + const values = []; + for (const line of rawTranscript.split(/\r?\n/)) { + const parsed = parseJsonLine(line); + if (parsed !== null) { + collectStrings(parsed, values); + } + } + return values; +} +function parseJsonLine(line) { + if (line.trim().length === 0) { + return null; + } + try { + const parsed = JSON.parse(line); + return parsed; + } + catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} +function collectStrings(value, output) { + if (typeof value === "string") { + output.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) { + collectStrings(item, output); + } + return; + } + if (!isRecord(value)) { + return; + } + for (const item of Object.values(value)) { + collectStrings(item, output); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts b/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts new file mode 100644 index 0000000..836f1ad --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts @@ -0,0 +1,16 @@ +import type { ReadonlyFileSystem } from "./types.js"; +export type PlanChecklist = { + readonly remaining: number; + readonly total: number; + readonly nextTaskLabel: string | null; +}; +export type ContinuationState = { + readonly planName: string; + readonly planPath: string; + readonly boulderPath: string; + readonly ledgerPath: string; + readonly worktreePath: string | null; + readonly checklist: PlanChecklist; +}; +export declare function parsePlanChecklist(markdown: string): PlanChecklist; +export declare function readContinuationState(cwd: string, sessionId: string, fs: ReadonlyFileSystem): ContinuationState | null; diff --git a/plugins/omo/components/start-work-continuation/dist/boulder-reader.js b/plugins/omo/components/start-work-continuation/dist/boulder-reader.js new file mode 100644 index 0000000..778870a --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/boulder-reader.js @@ -0,0 +1,146 @@ +import { isAbsolute, join, resolve } from "node:path"; +const CHECKBOX_PATTERN = /^- \[[ xX]\] /; +const UNCHECKED_PATTERN = /^- \[ \] /; +const TODO_HEADING = "TODOs"; +const FINAL_VERIFICATION_HEADING = "Final Verification Wave"; +export function parsePlanChecklist(markdown) { + const lines = markdown.split(/\r?\n/); + const hasCountedSections = lines.some(hasCountedSectionHeading); + let remaining = 0; + let total = 0; + let nextTaskLabel = null; + let isCountedSection = !hasCountedSections; + for (const line of lines) { + const heading = parseLevelTwoHeading(line); + if (heading !== null) + isCountedSection = isCountedHeading(heading); + if (!isCountedSection) + continue; + if (!CHECKBOX_PATTERN.test(line)) + continue; + total += 1; + if (!UNCHECKED_PATTERN.test(line)) + continue; + remaining += 1; + if (nextTaskLabel === null) + nextTaskLabel = line.slice("- [ ] ".length); + } + return { remaining, total, nextTaskLabel }; +} +function hasCountedSectionHeading(line) { + const heading = parseLevelTwoHeading(line); + return heading !== null && isCountedHeading(heading); +} +export function readContinuationState(cwd, sessionId, fs) { + const boulderPath = join(cwd, ".omo", "boulder.json"); + const boulderText = readTextFile(fs, boulderPath); + if (boulderText === null) + return null; + const parsed = parseJsonObject(boulderText); + if (parsed === null) + return null; + const work = findMatchingWork(parsed, `codex:${sessionId}`); + if (work === null) + return null; + const planPath = resolvePlanPath(cwd, work.activePlan); + const planText = readTextFile(fs, planPath); + if (planText === null) + return null; + const checklist = parsePlanChecklist(planText); + if (checklist.remaining === 0) + return null; + return { + planName: work.planName, + planPath, + boulderPath, + ledgerPath: join(cwd, ".omo", "start-work", "ledger.jsonl"), + worktreePath: work.worktreePath, + checklist, + }; +} +function findMatchingWork(state, prefixedSessionId) { + const worksValue = state["works"]; + const candidates = isRecord(worksValue) ? Object.values(worksValue) : [state]; + for (const candidate of candidates) { + const work = parseBoulderWork(candidate); + if (work === null) + continue; + if (!isContinuableStatus(work.status)) + continue; + if (work.sessionIds.includes(prefixedSessionId)) + return work; + } + return null; +} +function parseBoulderWork(value) { + if (!isRecord(value)) + return null; + const activePlan = value["active_plan"]; + const planName = value["plan_name"]; + const status = parseWorkStatus(value["status"]); + const sessionIds = value["session_ids"]; + const worktreePath = value["worktree_path"]; + if (typeof activePlan !== "string") + return null; + if (typeof planName !== "string") + return null; + if (status === null) + return null; + if (!isStringArray(sessionIds)) + return null; + return { + activePlan, + planName, + status, + sessionIds, + worktreePath: typeof worktreePath === "string" ? worktreePath : null, + }; +} +function parseWorkStatus(value) { + if (value === "active" || value === "completed" || value === "paused" || value === "abandoned") + return value; + return null; +} +function isContinuableStatus(status) { + return status === "active" || status === "paused"; +} +function parseLevelTwoHeading(line) { + if (!line.startsWith("## ")) + return null; + if (line.startsWith("### ")) + return null; + return line.slice("## ".length).trim(); +} +function isCountedHeading(heading) { + return heading === TODO_HEADING || heading === FINAL_VERIFICATION_HEADING; +} +function resolvePlanPath(cwd, activePlan) { + return isAbsolute(activePlan) ? activePlan : resolve(cwd, activePlan); +} +function readTextFile(fs, path) { + try { + return fs.readFileSync(path, "utf8"); + } + catch (error) { + if (error instanceof Error) + return null; + throw error; + } +} +function parseJsonObject(json) { + try { + const parsed = JSON.parse(json); + return isRecord(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + throw error; + } +} +function isStringArray(value) { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/dist/cli.d.ts b/plugins/omo/components/start-work-continuation/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/start-work-continuation/dist/cli.js b/plugins/omo/components/start-work-continuation/dist/cli.js new file mode 100644 index 0000000..5280a77 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/cli.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runStopHook } from "./codex-hook.js"; +const nodeFileSystem = { + readFileSync(path, encoding) { + return readFileSync(path, encoding); + }, +}; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && (subcommand === "stop" || subcommand === "subagent-stop")) { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-start-work-continuation hook \n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + const output = runStopHook(parsed, nodeFileSystem); + if (output.length > 0) + processStdout.write(output); +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch (error) { + if (error instanceof SyntaxError) + return undefined; + throw error; + } +} +function readStdin() { + return new Promise((resolve) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", () => resolve(data)); + processStdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts b/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts new file mode 100644 index 0000000..8b43585 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts @@ -0,0 +1,2 @@ +import type { ReadonlyFileSystem } from "./types.js"; +export declare function runStopHook(input: unknown, fs: ReadonlyFileSystem): string; diff --git a/plugins/omo/components/start-work-continuation/dist/codex-hook.js b/plugins/omo/components/start-work-continuation/dist/codex-hook.js new file mode 100644 index 0000000..0a7b5a8 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/codex-hook.js @@ -0,0 +1,80 @@ +import { readContinuationState } from "./boulder-reader.js"; +import { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; +export function runStopHook(input, fs) { + if (!isStopInput(input)) + return ""; + if (input.stop_hook_active) + return ""; + if (transcriptHasContextPressureMarker(input.transcript_path, fs)) + return ""; + const state = readContinuationState(input.cwd, input.session_id, fs); + if (state === null) + return ""; + return JSON.stringify({ + decision: "block", + reason: renderDirective(state, input.session_id), + }); +} +function renderDirective(state, sessionId) { + const lineBreak = String.fromCharCode(10); + const worktreeBlock = state.worktreePath === null + ? "" + : `${lineBreak}- Worktree: \`${state.worktreePath}\` (all edits, tests, and commands run inside this directory)`; + const replacements = { + PLAN_NAME: state.planName, + PLAN_PATH: state.planPath, + BOULDER_PATH: state.boulderPath, + REMAINING_COUNT: String(state.checklist.remaining), + TOTAL_COUNT: String(state.checklist.total), + NEXT_TASK_LABEL: state.checklist.nextTaskLabel ?? "", + WORKTREE_BLOCK: worktreeBlock, + LEDGER_PATH: state.ledgerPath, + SESSION_ID: sessionId, + }; + let rendered = START_WORK_CONTINUATION_DIRECTIVE; + for (const [placeholder, value] of Object.entries(replacements)) { + rendered = rendered.replaceAll(`{{${placeholder}}}`, value); + } + return rendered; +} +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +]; +function transcriptHasContextPressureMarker(transcriptPath, fs) { + try { + const transcript = fs.readFileSync(transcriptPath, "utf8").toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => transcript.includes(marker)); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function isStopInput(value) { + return (isRecord(value) && + isStopHookEventName(value["hook_event_name"]) && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + typeof value["transcript_path"] === "string" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["stop_hook_active"] === "boolean" && + optionalString(value["last_assistant_message"])); +} +function isStopHookEventName(value) { + return value === "Stop" || value === "SubagentStop"; +} +function optionalString(value) { + return value === undefined || typeof value === "string"; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/dist/directive.d.ts b/plugins/omo/components/start-work-continuation/dist/directive.d.ts new file mode 100644 index 0000000..21bb6eb --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/directive.d.ts @@ -0,0 +1 @@ +export declare const START_WORK_CONTINUATION_DIRECTIVE: string; diff --git a/plugins/omo/components/start-work-continuation/dist/directive.js b/plugins/omo/components/start-work-continuation/dist/directive.js new file mode 100644 index 0000000..2e968cf --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/directive.js @@ -0,0 +1,2 @@ +import { readFileSync } from "node:fs"; +export const START_WORK_CONTINUATION_DIRECTIVE = readFileSync(new URL("../directive.md", import.meta.url), "utf8"); diff --git a/plugins/omo/components/start-work-continuation/dist/index.d.ts b/plugins/omo/components/start-work-continuation/dist/index.d.ts new file mode 100644 index 0000000..fd51acd --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/index.d.ts @@ -0,0 +1,5 @@ +export type { ContinuationState, PlanChecklist } from "./boulder-reader.js"; +export { parsePlanChecklist, readContinuationState } from "./boulder-reader.js"; +export { runStopHook } from "./codex-hook.js"; +export { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; +export type { ReadonlyFileSystem, StopHookEventName, StopHookOutput, StopInput } from "./types.js"; diff --git a/plugins/omo/components/start-work-continuation/dist/index.js b/plugins/omo/components/start-work-continuation/dist/index.js new file mode 100644 index 0000000..061ae60 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/index.js @@ -0,0 +1,3 @@ +export { parsePlanChecklist, readContinuationState } from "./boulder-reader.js"; +export { runStopHook } from "./codex-hook.js"; +export { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; diff --git a/plugins/omo/components/start-work-continuation/dist/types.d.ts b/plugins/omo/components/start-work-continuation/dist/types.d.ts new file mode 100644 index 0000000..8bbccd9 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/types.d.ts @@ -0,0 +1,20 @@ +export declare const STOP_HOOK_EVENTS: readonly ["Stop", "SubagentStop"]; +export type StopHookEventName = (typeof STOP_HOOK_EVENTS)[number]; +export type StopInput = { + readonly hook_event_name: StopHookEventName; + readonly session_id: string; + readonly turn_id: string; + readonly transcript_path: string; + readonly cwd: string; + readonly model: string; + readonly permission_mode: string; + readonly stop_hook_active: boolean; + readonly last_assistant_message?: string; +}; +export type StopHookOutput = { + readonly decision: "block"; + readonly reason: string; +}; +export type ReadonlyFileSystem = { + readFileSync(path: string, encoding: "utf8"): string; +}; diff --git a/plugins/omo/components/start-work-continuation/dist/types.js b/plugins/omo/components/start-work-continuation/dist/types.js new file mode 100644 index 0000000..d70340a --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/types.js @@ -0,0 +1 @@ +export const STOP_HOOK_EVENTS = ["Stop", "SubagentStop"]; diff --git a/plugins/omo/components/telemetry/dist/atomic-write.d.ts b/plugins/omo/components/telemetry/dist/atomic-write.d.ts new file mode 100644 index 0000000..ae1b009 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/atomic-write.d.ts @@ -0,0 +1 @@ +export declare function writeFileAtomically(filePath: string, content: string): void; diff --git a/plugins/omo/components/telemetry/dist/atomic-write.js b/plugins/omo/components/telemetry/dist/atomic-write.js new file mode 100644 index 0000000..9f19268 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/atomic-write.js @@ -0,0 +1,18 @@ +import { renameSync, unlinkSync, writeFileSync } from "node:fs"; +export function writeFileAtomically(filePath, content) { + const tempPath = `${filePath}.tmp`; + writeFileSync(tempPath, content, "utf-8"); + try { + renameSync(tempPath, filePath); + } + catch (error) { + const isPermissionError = error instanceof Error && + (error.message.includes("EPERM") || error.message.includes("EACCES")); + if (process.platform === "win32" && isPermissionError) { + unlinkSync(filePath); + renameSync(tempPath, filePath); + return; + } + throw error; + } +} diff --git a/plugins/omo/components/telemetry/dist/cli.d.ts b/plugins/omo/components/telemetry/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/telemetry/dist/cli.js b/plugins/omo/components/telemetry/dist/cli.js new file mode 100644 index 0000000..e5c4dca --- /dev/null +++ b/plugins/omo/components/telemetry/dist/cli.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runSessionStartHook } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "session-start") { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-telemetry hook session-start\n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + if (!isCodexSessionStartInput(parsed)) + return; + const output = await runSessionStartHook(parsed); + if (output.length > 0) { + processStdout.write(output); + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch { + return undefined; + } +} +function isCodexSessionStartInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string"); +} +function isStringOrNull(value) { + return typeof value === "string" || value === null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/telemetry/dist/codex-hook.d.ts b/plugins/omo/components/telemetry/dist/codex-hook.d.ts new file mode 100644 index 0000000..33e8a1b --- /dev/null +++ b/plugins/omo/components/telemetry/dist/codex-hook.d.ts @@ -0,0 +1,15 @@ +import { type PostHogClient } from "./posthog.js"; +export type CodexSessionStartInput = { + session_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "SessionStart"; + model: string; + permission_mode: string; + source: "startup" | "resume" | "clear"; +}; +export type CodexTelemetryHookOptions = { + createClient?: () => PostHogClient | Promise; + getDistinctId?: () => string; +}; +export declare function runSessionStartHook(_input: CodexSessionStartInput, options?: CodexTelemetryHookOptions): Promise; diff --git a/plugins/omo/components/telemetry/dist/codex-hook.js b/plugins/omo/components/telemetry/dist/codex-hook.js new file mode 100644 index 0000000..90ef700 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/codex-hook.js @@ -0,0 +1,42 @@ +import { writeTelemetryDiagnostic, } from "./diagnostics.js"; +import { createPluginPostHog, getPostHogDistinctId, } from "./posthog.js"; +const SESSION_START_REASON = "session_start"; +function writeHookDiagnostic(event, error, errorKind) { + writeTelemetryDiagnostic({ + event, + source: "plugin", + error, + errorKind, + }); +} +export async function runSessionStartHook(_input, options = {}) { + const createClient = options.createClient ?? createPluginPostHog; + const getDistinctId = options.getDistinctId ?? getPostHogDistinctId; + let client; + try { + client = await createClient(); + } + catch (error) { + writeHookDiagnostic("telemetry_posthog_init_failed", error, error instanceof Error ? "error" : "non_error"); + return ""; + } + try { + client.trackActive(getDistinctId(), SESSION_START_REASON); + } + catch (error) { + writeHookDiagnostic("telemetry_capture_failed", error, error instanceof Error ? "error" : "non_error"); + await safeShutdown(client); + return ""; + } + await safeShutdown(client); + return ""; +} +async function safeShutdown(client) { + try { + await client.shutdown(); + } + catch (error) { + writeHookDiagnostic("telemetry_shutdown_failed", error, error instanceof Error ? "error" : "non_error"); + return; + } +} diff --git a/plugins/omo/components/telemetry/dist/data-path.d.ts b/plugins/omo/components/telemetry/dist/data-path.d.ts new file mode 100644 index 0000000..7781d54 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/data-path.d.ts @@ -0,0 +1,10 @@ +import os from "node:os"; +type OsProvider = Pick; +export declare function getOsProvider(): OsProvider; +/** @internal test-only */ +export declare function __setOsProviderForTesting(provider: OsProvider): void; +/** @internal test-only */ +export declare function __resetOsProviderForTesting(): void; +export declare function getDataDir(): string; +export declare function getActivityStateDir(): string; +export {}; diff --git a/plugins/omo/components/telemetry/dist/data-path.js b/plugins/omo/components/telemetry/dist/data-path.js new file mode 100644 index 0000000..dcfffb3 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/data-path.js @@ -0,0 +1,35 @@ +import { accessSync, constants, mkdirSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CACHE_DIR_NAME } from "./product-identity.js"; +let osProviderOverride = null; +export function getOsProvider() { + return osProviderOverride ?? os; +} +/** @internal test-only */ +export function __setOsProviderForTesting(provider) { + osProviderOverride = provider; +} +/** @internal test-only */ +export function __resetOsProviderForTesting() { + osProviderOverride = null; +} +function resolveWritableDirectory(preferredDir, fallbackSuffix) { + try { + mkdirSync(preferredDir, { recursive: true }); + accessSync(preferredDir, constants.W_OK); + return preferredDir; + } + catch { + const fallbackDir = path.join(getOsProvider().tmpdir(), fallbackSuffix); + mkdirSync(fallbackDir, { recursive: true }); + return fallbackDir; + } +} +export function getDataDir() { + const preferredDataDir = process.env["XDG_DATA_HOME"] ?? path.join(getOsProvider().homedir(), ".local", "share"); + return resolveWritableDirectory(preferredDataDir, "omo-codex-data"); +} +export function getActivityStateDir() { + return path.join(getDataDir(), CACHE_DIR_NAME); +} diff --git a/plugins/omo/components/telemetry/dist/diagnostics.d.ts b/plugins/omo/components/telemetry/dist/diagnostics.d.ts new file mode 100644 index 0000000..10f6dff --- /dev/null +++ b/plugins/omo/components/telemetry/dist/diagnostics.d.ts @@ -0,0 +1,12 @@ +export type TelemetryDiagnosticEvent = "telemetry_activity_state_read_failed" | "telemetry_activity_state_write_failed" | "telemetry_capture_failed" | "telemetry_cpu_info_unavailable" | "telemetry_posthog_import_failed" | "telemetry_posthog_init_failed" | "telemetry_shutdown_failed"; +export type TelemetryDiagnosticSource = "cli" | "install" | "plugin" | "shared"; +export type TelemetryDiagnosticErrorKind = "error" | "non_error"; +export type TelemetryDiagnosticInput = { + readonly event: TelemetryDiagnosticEvent; + readonly source: TelemetryDiagnosticSource; + readonly error?: unknown; + readonly errorKind?: TelemetryDiagnosticErrorKind; +}; +export declare function getTelemetryDiagnosticsFilePath(): string; +export declare function writeTelemetryDiagnostic(input: TelemetryDiagnosticInput, now?: Date): void; +export declare function cleanupTelemetryDiagnostics(now?: Date): void; diff --git a/plugins/omo/components/telemetry/dist/diagnostics.js b/plugins/omo/components/telemetry/dist/diagnostics.js new file mode 100644 index 0000000..2ac9029 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/diagnostics.js @@ -0,0 +1,108 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeFileAtomically } from "./atomic-write.js"; +import { getActivityStateDir } from "./data-path.js"; +const DIAGNOSTICS_FILE_NAME = "telemetry-diagnostics.jsonl"; +const DIAGNOSTICS_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const DIAGNOSTICS_MAX_BYTES = 256 * 1024; +export function getTelemetryDiagnosticsFilePath() { + return join(getActivityStateDir(), DIAGNOSTICS_FILE_NAME); +} +export function writeTelemetryDiagnostic(input, now = new Date()) { + try { + cleanupTelemetryDiagnostics(now); + mkdirSync(getActivityStateDir(), { recursive: true }); + appendFileSync(getTelemetryDiagnosticsFilePath(), `${JSON.stringify(toDiagnosticRecord(input, now))}\n`, "utf-8"); + } + catch { + return; + } +} +export function cleanupTelemetryDiagnostics(now = new Date()) { + const diagnosticsFilePath = getTelemetryDiagnosticsFilePath(); + if (!existsSync(diagnosticsFilePath)) { + return; + } + try { + const cutoffMs = now.getTime() - DIAGNOSTICS_RETENTION_MS; + const retainedLines = trimToMaxBytes(readFileSync(diagnosticsFilePath, "utf-8") + .split("\n") + .filter((line) => shouldRetainLine(line, cutoffMs))); + writeFileAtomically(diagnosticsFilePath, retainedLines.length === 0 ? "" : `${retainedLines.join("\n")}\n`); + } + catch { + return; + } +} +function toDiagnosticRecord(input, now) { + return { + timestamp: now.toISOString(), + event: input.event, + source: input.source, + ...serializeError(input.error, input.errorKind), + }; +} +function serializeError(error, errorKind) { + if (error instanceof Error) { + return { + error_kind: errorKind ?? "error", + error_name: error.name, + error_message: error.message, + }; + } + if (error === undefined) { + return {}; + } + return { + error_kind: errorKind ?? "non_error", + error_name: typeof error, + error_message: String(error), + }; +} +function shouldRetainLine(line, cutoffMs) { + if (line.length === 0) { + return false; + } + const parsed = parseDiagnosticLine(line); + if (parsed === null) { + return false; + } + const timestamp = parsed["timestamp"]; + if (typeof timestamp !== "string") { + return false; + } + const timestampMs = Date.parse(timestamp); + return Number.isFinite(timestampMs) && timestampMs >= cutoffMs; +} +function parseDiagnosticLine(line) { + try { + const parsed = JSON.parse(line); + if (!isRecord(parsed)) { + return null; + } + return parsed; + } + catch { + return null; + } +} +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function trimToMaxBytes(lines) { + const retained = []; + let totalBytes = 0; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (line === undefined) { + continue; + } + const lineBytes = Buffer.byteLength(`${line}\n`, "utf-8"); + if (totalBytes + lineBytes > DIAGNOSTICS_MAX_BYTES) { + break; + } + retained.unshift(line); + totalBytes += lineBytes; + } + return retained; +} diff --git a/plugins/omo/components/telemetry/dist/env-flags.d.ts b/plugins/omo/components/telemetry/dist/env-flags.d.ts new file mode 100644 index 0000000..b3889c7 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/env-flags.d.ts @@ -0,0 +1,4 @@ +export declare function shouldDisablePostHog(): boolean; +export declare function getPostHogApiKey(): string; +export declare function hasPostHogApiKey(): boolean; +export declare function getPostHogHost(): string; diff --git a/plugins/omo/components/telemetry/dist/env-flags.js b/plugins/omo/components/telemetry/dist/env-flags.js new file mode 100644 index 0000000..a446da4 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/env-flags.js @@ -0,0 +1,31 @@ +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST, } from "./product-identity.js"; +function normalizeEnvValue(value) { + return value?.trim().toLowerCase(); +} +function isDisableFlag(value) { + const normalized = normalizeEnvValue(value); + return normalized === "1" || normalized === "true"; +} +function isTelemetryOptOutFlag(value) { + const normalized = normalizeEnvValue(value); + return normalized === "0" || normalized === "false" || normalized === "no"; +} +export function shouldDisablePostHog() { + return (isDisableFlag(process.env["OMO_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_SEND_ANONYMOUS_TELEMETRY"]) || + isDisableFlag(process.env["OMO_CODEX_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"])); +} +export function getPostHogApiKey() { + const explicit = process.env["POSTHOG_API_KEY"]; + if (explicit === undefined) { + return DEFAULT_POSTHOG_API_KEY; + } + return explicit.trim(); +} +export function hasPostHogApiKey() { + return getPostHogApiKey().length > 0; +} +export function getPostHogHost() { + return process.env["POSTHOG_HOST"]?.trim() || DEFAULT_POSTHOG_HOST; +} diff --git a/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts b/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts new file mode 100644 index 0000000..9addfdb --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts @@ -0,0 +1,8 @@ +export type PostHogActivityState = { + readonly lastActiveDayUTC?: string; +}; +export type PostHogActivityCaptureState = { + readonly dayUTC: string; + readonly captureDaily: boolean; +}; +export declare function getPostHogActivityCaptureState(now?: Date): PostHogActivityCaptureState; diff --git a/plugins/omo/components/telemetry/dist/posthog-activity-state.js b/plugins/omo/components/telemetry/dist/posthog-activity-state.js new file mode 100644 index 0000000..29543a4 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog-activity-state.js @@ -0,0 +1,68 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeFileAtomically } from "./atomic-write.js"; +import { getActivityStateDir } from "./data-path.js"; +import { writeTelemetryDiagnostic } from "./diagnostics.js"; +const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json"; +function getPostHogActivityStateFilePath() { + return join(getActivityStateDir(), POSTHOG_ACTIVITY_STATE_FILE); +} +function getUtcDayString(date) { + return date.toISOString().slice(0, 10); +} +function isPostHogActivityState(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function writeActivityStateDiagnostic(event, error, errorKind) { + writeTelemetryDiagnostic({ + event, + source: "shared", + error, + errorKind, + }); +} +function readPostHogActivityState() { + const stateFilePath = getPostHogActivityStateFilePath(); + if (!existsSync(stateFilePath)) { + return {}; + } + try { + const stateContent = readFileSync(stateFilePath, "utf-8"); + const stateJson = JSON.parse(stateContent); + if (!isPostHogActivityState(stateJson)) { + return {}; + } + return stateJson; + } + catch (error) { + writeActivityStateDiagnostic("telemetry_activity_state_read_failed", error, error instanceof Error ? "error" : "non_error"); + return {}; + } +} +function writePostHogActivityState(nextState) { + const stateDir = getActivityStateDir(); + const stateFilePath = getPostHogActivityStateFilePath(); + try { + mkdirSync(stateDir, { recursive: true }); + writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`); + } + catch (error) { + writeActivityStateDiagnostic("telemetry_activity_state_write_failed", error, error instanceof Error ? "error" : "non_error"); + return; + } +} +export function getPostHogActivityCaptureState(now = new Date()) { + const state = readPostHogActivityState(); + const dayUTC = getUtcDayString(now); + const captureDaily = state.lastActiveDayUTC !== dayUTC; + if (captureDaily) { + writePostHogActivityState({ + ...state, + lastActiveDayUTC: dayUTC, + }); + } + return { + dayUTC, + captureDaily, + }; +} diff --git a/plugins/omo/components/telemetry/dist/posthog.d.ts b/plugins/omo/components/telemetry/dist/posthog.d.ts new file mode 100644 index 0000000..22051c6 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog.d.ts @@ -0,0 +1,21 @@ +import os from "node:os"; +import { getPostHogActivityCaptureState } from "./posthog-activity-state.js"; +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST } from "./product-identity.js"; +export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST }; +export type PostHogActivityReason = "session_start"; +export type PostHogClient = { + trackActive: (distinctId: string, reason: PostHogActivityReason) => void; + shutdown: () => Promise; +}; +type OsProvider = Pick; +type ActivityStateProvider = typeof getPostHogActivityCaptureState; +export declare function createPluginPostHog(): Promise; +export declare function getPostHogDistinctId(): string; +/** @internal test-only */ +export declare function __setOsProviderForTesting(provider: OsProvider): void; +/** @internal test-only */ +export declare function __resetOsProviderForTesting(): void; +/** @internal test-only */ +export declare function __setActivityStateProviderForTesting(provider: ActivityStateProvider): void; +/** @internal test-only */ +export declare function __resetActivityStateProviderForTesting(): void; diff --git a/plugins/omo/components/telemetry/dist/posthog.js b/plugins/omo/components/telemetry/dist/posthog.js new file mode 100644 index 0000000..3a8073c --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog.js @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import os from "node:os"; +import { writeTelemetryDiagnostic, } from "./diagnostics.js"; +import { getPostHogApiKey, getPostHogHost, hasPostHogApiKey, shouldDisablePostHog } from "./env-flags.js"; +import { getPostHogActivityCaptureState } from "./posthog-activity-state.js"; +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST, EVENT_NAME, getComponentVersion, PACKAGE_NAME, PRODUCT_NAME, } from "./product-identity.js"; +export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST }; +let osProviderOverride = null; +let activityStateProviderOverride = null; +const NO_OP_POSTHOG = { + trackActive: () => undefined, + shutdown: async () => undefined, +}; +function resolveOsProvider() { + return osProviderOverride ?? os; +} +function resolveActivityStateProvider() { + return activityStateProviderOverride ?? getPostHogActivityCaptureState; +} +function writePostHogDiagnostic(event, source, error, errorKind) { + writeTelemetryDiagnostic({ event, source, error, errorKind }); +} +function getSafeCpuInfo() { + try { + const cpuInfo = resolveOsProvider().cpus(); + return { + count: cpuInfo.length, + model: cpuInfo[0]?.model, + }; + } + catch (error) { + writePostHogDiagnostic("telemetry_cpu_info_unavailable", "plugin", error, error instanceof Error ? "error" : "non_error"); + return { + count: 0, + model: undefined, + }; + } +} +function getSharedProperties() { + const osProvider = resolveOsProvider(); + const cpuInfo = getSafeCpuInfo(); + return { + platform: "omo-codex", + product_name: PRODUCT_NAME, + package_name: PACKAGE_NAME, + package_version: getComponentVersion(), + runtime: "node", + runtime_version: process.version, + source: "plugin", + $os: osProvider.platform(), + $os_version: osProvider.release(), + os_arch: osProvider.arch(), + os_type: osProvider.type(), + cpu_count: cpuInfo.count, + cpu_model: cpuInfo.model, + total_memory_gb: Math.round(osProvider.totalmem() / 1024 / 1024 / 1024), + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + shell: process.env["SHELL"], + ci: Boolean(process.env["CI"]), + terminal: process.env["TERM_PROGRAM"], + }; +} +export async function createPluginPostHog() { + if (shouldDisablePostHog() || !hasPostHogApiKey()) { + return NO_OP_POSTHOG; + } + let PostHogClientConstructor; + try { + const module = await import("posthog-node"); + PostHogClientConstructor = module.PostHog; + } + catch (error) { + writePostHogDiagnostic("telemetry_posthog_import_failed", "plugin", error, error instanceof Error ? "error" : "non_error"); + return NO_OP_POSTHOG; + } + let client; + try { + client = new PostHogClientConstructor(getPostHogApiKey(), { + enableExceptionAutocapture: false, + enableLocalEvaluation: false, + strictLocalEvaluation: true, + disableRemoteConfig: true, + flushAt: 1, + flushInterval: 0, + host: getPostHogHost(), + disableGeoip: false, + }); + } + catch (error) { + writePostHogDiagnostic("telemetry_posthog_init_failed", "plugin", error, error instanceof Error ? "error" : "non_error"); + return NO_OP_POSTHOG; + } + const sharedProperties = getSharedProperties(); + return { + trackActive: (distinctId, reason) => { + const activityState = resolveActivityStateProvider()(); + if (!activityState.captureDaily) { + return; + } + client.capture({ + distinctId, + event: EVENT_NAME, + properties: { + ...sharedProperties, + $process_person_profile: false, + day_utc: activityState.dayUTC, + reason, + }, + }); + }, + shutdown: async () => client.shutdown(), + }; +} +export function getPostHogDistinctId() { + return createHash("sha256").update(`omo-codex:${resolveOsProvider().hostname()}`).digest("hex"); +} +/** @internal test-only */ +export function __setOsProviderForTesting(provider) { + osProviderOverride = provider; +} +/** @internal test-only */ +export function __resetOsProviderForTesting() { + osProviderOverride = null; +} +/** @internal test-only */ +export function __setActivityStateProviderForTesting(provider) { + activityStateProviderOverride = provider; +} +/** @internal test-only */ +export function __resetActivityStateProviderForTesting() { + activityStateProviderOverride = null; +} diff --git a/plugins/omo/components/telemetry/dist/product-identity.d.ts b/plugins/omo/components/telemetry/dist/product-identity.d.ts new file mode 100644 index 0000000..66a529a --- /dev/null +++ b/plugins/omo/components/telemetry/dist/product-identity.d.ts @@ -0,0 +1,8 @@ +export declare const PRODUCT_NAME = "omo-codex"; +export declare const PACKAGE_NAME = "@oh-my-opencode/omo-codex"; +export declare const CACHE_DIR_NAME = "omo-codex"; +export declare const EVENT_NAME = "omo_codex_daily_active"; +export declare const LEGACY_PARENT_PACKAGE = "oh-my-opencode"; +export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +export declare const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"; +export declare function getComponentVersion(): string; diff --git a/plugins/omo/components/telemetry/dist/product-identity.js b/plugins/omo/components/telemetry/dist/product-identity.js new file mode 100644 index 0000000..079a348 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/product-identity.js @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +export const PRODUCT_NAME = "omo-codex"; +export const PACKAGE_NAME = "@oh-my-opencode/omo-codex"; +export const CACHE_DIR_NAME = "omo-codex"; +export const EVENT_NAME = "omo_codex_daily_active"; +export const LEGACY_PARENT_PACKAGE = "oh-my-opencode"; +export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +export const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"; +function isComponentPackageManifest(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function readComponentVersionFromManifest() { + try { + const manifestUrl = new URL("../package.json", import.meta.url); + const manifestText = readFileSync(manifestUrl, "utf-8"); + const parsed = JSON.parse(manifestText); + if (isComponentPackageManifest(parsed) && typeof parsed.version === "string") { + return parsed.version; + } + } + catch { + return "0.0.0"; + } + return "0.0.0"; +} +const COMPONENT_VERSION_CACHE = readComponentVersionFromManifest(); +export function getComponentVersion() { + return COMPONENT_VERSION_CACHE; +} diff --git a/plugins/omo/components/ultrawork/dist/cli.d.ts b/plugins/omo/components/ultrawork/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/ultrawork/dist/cli.js b/plugins/omo/components/ultrawork/dist/cli.js new file mode 100644 index 0000000..22e2e47 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/cli.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runUserPromptSubmitHook } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "user-prompt-submit") { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-ultrawork hook user-prompt-submit\n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + const output = runUserPromptSubmitHook(parsed); + if (output.length > 0) { + processStdout.write(output); + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch (error) { + if (error instanceof SyntaxError) + return undefined; + throw error; + } +} +function readStdin() { + return new Promise((resolve) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", () => { + resolve(data); + }); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/ultrawork/dist/codex-hook.d.ts b/plugins/omo/components/ultrawork/dist/codex-hook.d.ts new file mode 100644 index 0000000..f0683d0 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/codex-hook.d.ts @@ -0,0 +1,7 @@ +export type CodexUserPromptSubmitInput = { + readonly hook_event_name: "UserPromptSubmit"; + readonly prompt: string; + readonly transcript_path?: string | null; +}; +export declare function runUserPromptSubmitHook(input: unknown): string; +export declare function isUltraworkPrompt(prompt: string): boolean; diff --git a/plugins/omo/components/ultrawork/dist/codex-hook.js b/plugins/omo/components/ultrawork/dist/codex-hook.js new file mode 100644 index 0000000..463c288 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/codex-hook.js @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { ULTRAWORK_DIRECTIVE } from "./directive.js"; +const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i; +const ULTRAWORK_DIRECTIVE_MARKER = ""; +const TRANSCRIPT_SEARCH_BYTES = 512_000; +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +]; +export function runUserPromptSubmitHook(input) { + if (!isCodexUserPromptSubmitInput(input)) + return ""; + if (isContextPressureRecoveryPrompt(input.prompt)) + return ""; + if (hasUltraworkDirectiveAlreadyInTranscript(input.transcript_path)) + return ""; + if (isContextPressureTranscript(input.transcript_path)) + return ""; + return isUltraworkPrompt(input.prompt) ? formatAdditionalContextOutput(ULTRAWORK_DIRECTIVE) : ""; +} +function hasUltraworkDirectiveAlreadyInTranscript(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + const rawTranscript = readTranscriptTail(transcriptPath); + for (const line of rawTranscript.split(/\r?\n/)) { + const parsed = parseJsonLine(line); + if (parsed === null) { + continue; + } + if (!isRecord(parsed)) { + continue; + } + const hookSpecificOutput = parsed["hookSpecificOutput"]; + if (!isRecord(hookSpecificOutput)) { + continue; + } + if (hookSpecificOutput["hookEventName"] !== "UserPromptSubmit") { + continue; + } + if (typeof hookSpecificOutput["additionalContext"] === "string" && + hookSpecificOutput["additionalContext"].includes(ULTRAWORK_DIRECTIVE_MARKER)) { + return true; + } + } + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } + return false; +} +function readTranscriptTail(transcriptPath) { + const rawTranscript = readFileSync(transcriptPath); + return rawTranscript.subarray(Math.max(0, rawTranscript.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8"); +} +export function isUltraworkPrompt(prompt) { + return ULTRAWORK_PATTERN.test(prompt); +} +function isContextPressureRecoveryPrompt(prompt) { + const normalizedPrompt = prompt.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedPrompt.includes(marker)); +} +function isContextPressureTranscript(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + return isContextPressureRecoveryPrompt(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function formatAdditionalContextOutput(additionalContext) { + const normalizedContext = normalizeAdditionalContext(additionalContext); + if (normalizedContext.length === 0) + return ""; + const output = { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: normalizedContext, + }, + }; + return `${JSON.stringify(output)}\n`; +} +function normalizeAdditionalContext(additionalContext) { + return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function parseJsonLine(line) { + if (line.trim().length === 0) { + return null; + } + try { + const parsed = JSON.parse(line); + return parsed; + } + catch (error) { + if (error instanceof Error) { + return null; + } + throw error; + } +} +function isCodexUserPromptSubmitInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["prompt"] === "string" && + (value["transcript_path"] === undefined || + value["transcript_path"] === null || + typeof value["transcript_path"] === "string")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/ultrawork/dist/directive.d.ts b/plugins/omo/components/ultrawork/dist/directive.d.ts new file mode 100644 index 0000000..18d2306 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/directive.d.ts @@ -0,0 +1 @@ +export declare const ULTRAWORK_DIRECTIVE: string; diff --git a/plugins/omo/components/ultrawork/dist/directive.js b/plugins/omo/components/ultrawork/dist/directive.js new file mode 100644 index 0000000..a5da6cb --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/directive.js @@ -0,0 +1,2 @@ +import { readFileSync } from "node:fs"; +export const ULTRAWORK_DIRECTIVE = readFileSync(new URL("../directive.md", import.meta.url), "utf8"); diff --git a/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts b/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts new file mode 100644 index 0000000..c604b27 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts @@ -0,0 +1,16 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopAggregateCompletion, UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export interface CheckpointUlwLoopArgs { + readonly goalId: string; + readonly status: "complete" | "failed" | "blocked"; + readonly evidence: string; + readonly codexGoalJson?: string; + readonly qualityGateJson?: string; +} +export interface CheckpointUlwLoopResult { + readonly plan: UlwLoopPlan; + readonly goal: UlwLoopItem; + readonly ledgerEntry: UlwLoopLedgerEntry; + readonly aggregateCompletion?: UlwLoopAggregateCompletion; +} +export declare function checkpointUlwLoop(repoRoot: string, args: CheckpointUlwLoopArgs, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/checkpoint.js b/plugins/omo/components/ulw-loop/dist/checkpoint.js new file mode 100644 index 0000000..98fa0b5 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/checkpoint.js @@ -0,0 +1,200 @@ +// biome-ignore-all format: keep checkpoint orchestration below the pure LOC budget. +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { formatCodexGoalReconciliation, readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { requireAllCriteriaPass } from "./evidence.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { ulwLoopBriefPath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { classifyExternalAuthorizationBlocker, clearGoalBlockerFields, sameBlockerOccurrences, validateQualityGate } from "./quality-gate.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; +function ulwLoopFail(message, code) { throw new UlwLoopError(message, code); } +function normalizeObjective(value) { return value.replace(/\s+/g, " ").trim(); } +function nonEmptyEvidence(value) { const trimmed = value.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ulw_loop_evidence_required"); } +function findGoal(plan, goalId) { const goal = plan.goals.find((candidate) => candidate.id === goalId); return goal ?? ulwLoopFail(`Unknown ulw-loop id: ${goalId}.`, "ulw_loop_goal_not_found"); } +function textMentionsUlwLoopPlanArtifact(value) { + const normalized = (value ?? "").toLowerCase(); + return normalized.includes(ULW_LOOP_DIR.toLowerCase()) || normalized.includes(ULW_LOOP_GOALS.toLowerCase()) || normalized.includes(ULW_LOOP_LEDGER.toLowerCase()); +} +function textMentionsGoalId(value, goalId) { return (value ?? "").toLowerCase().includes(goalId.toLowerCase()); } +function textHasCompletionValidationEvidence(value) { + const normalized = (value ?? "").toLowerCase(); + const done = /\b(?:planned work|implementation|deliverables?|scope|task|work)\b/.test(normalized) && /\b(?:done|complete|completed|finished|shipped)\b/.test(normalized); + const verified = /\b(?:validation|verification|tests?|build|lint|review|quality gate|code-review)\b/.test(normalized) && /\b(?:passed|complete|completed|clean|green|approve|approved|clear)\b/.test(normalized); + return done && verified; +} +async function snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope) { + const actual = normalizeObjective(snapshotObjective).toLowerCase(); + if (textMentionsUlwLoopPlanArtifact(actual)) + return true; + if (actual.length < 24 || !existsSync(ulwLoopBriefPath(repoRoot, scope))) + return false; + try { + const brief = normalizeObjective(await readFile(ulwLoopBriefPath(repoRoot, scope), "utf8")).toLowerCase(); + return brief.length >= 24 && (brief.includes(actual) || actual.includes(brief)); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +async function canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, snapshotObjective, evidence, scope) { + if (codexGoalMode(plan) !== "aggregate") + return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) + return false; + if (isFinalRunCompletionCandidate(plan, goal)) + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); + if (!textMentionsUlwLoopPlanArtifact(evidence) || !textMentionsGoalId(evidence, goal.id)) + return false; + if (!textHasCompletionValidationEvidence(evidence)) + return false; + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); +} +async function canReconcileActiveFinalTaskScopedAggregateSnapshot(repoRoot, plan, goal, snapshotObjective, evidence, scope) { + if (codexGoalMode(plan) !== "aggregate") + return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) + return false; + if (!isFinalRunCompletionCandidate(plan, goal)) + return false; + if (!textHasCompletionValidationEvidence(evidence)) + return false; + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); +} +function buildCompletedLegacyGoalRemediation(goal) { + return [ + "If get_goal returns a different completed legacy/thread objective, do not repeat --status complete in this thread.", + `Record a non-terminal blocker with: omo ulw-loop checkpoint --goal-id ${goal.id} --status blocked --evidence "" --codex-goal-json "".`, + "Then continue only from a Codex goal context with no active/completed conflicting goal, in the same repo/worktree, and create the intended goal there.", + ].join(" "); +} +function buildTaskScopedAggregateReconciliationHint(goal, final) { + if (final) { + return ` Final task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress final OMO goal and the completed get_goal objective to map to the ulw-loop brief or artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; + } + return ` Completed task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress OMO goal, evidence that names that active OMO goal id, names .omo/ulw-loop/goals.json or ledger.jsonl, includes completed implementation plus validation/review evidence, and a get_goal objective that maps to the ulw-loop brief/artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; +} +async function readJsonInput(raw, repoRoot) { + if (raw === undefined || raw.trim() === "") + return undefined; + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed); + } + catch (error) { + if (!(error instanceof SyntaxError)) + throw error; + } + const path = resolve(repoRoot, trimmed); + if (!existsSync(path)) + return ulwLoopFail("Quality gate JSON is neither valid JSON nor a readable path.", "ulw_loop_json_input_invalid"); + try { + return JSON.parse(await readFile(path, "utf8")); + } + catch (error) { + return ulwLoopFail(`Quality gate path does not contain valid JSON${error instanceof Error ? `: ${error.message}` : "."}`, "ulw_loop_json_input_invalid"); + } +} +function makeAggregateCompletion(now, evidence, codexGoal) { + return { status: "complete", completedAt: now, evidence, codexGoal }; +} +function applyBlockedOrFailed(goal, plan, status, evidence, now) { + const signature = classifyExternalAuthorizationBlocker(evidence); + const occurrences = signature === null ? 0 : sameBlockerOccurrences(plan, signature) + 1; + const needsDecision = signature !== null && occurrences >= 3; + goal.status = needsDecision ? "needs_user_decision" : status; + goal.updatedAt = now; + if (status === "failed" || needsDecision) { + goal.failedAt = now; + goal.failureReason = evidence; + } + if (status === "blocked" || needsDecision) + goal.blockedReason = evidence; + if (signature !== null) { + goal.blockerSignature = signature; + goal.blockerOccurrenceCount = occurrences; + goal.requiredExternalDecision = `Resolve external authorization: ${signature}`; + } + if (needsDecision) + goal.nonRetriable = true; + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; +} +function ledgerKind(status, goal, aggregateCompletion) { + if (aggregateCompletion !== undefined) + return "aggregate_completed"; + if (status === "complete") + return "goal_completed"; + if (goal.status === "needs_user_decision") + return "goal_needs_user_decision"; + return status === "blocked" ? "goal_blocked" : "goal_failed"; +} +function buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion) { + const entry = { at: now, kind: ledgerKind(args.status, goal, aggregateCompletion), goalId: goal.id, status: goal.status, evidence: args.evidence }; + if (codexGoal !== undefined) + entry.codexGoal = codexGoal; + if (qualityGate !== undefined) + entry.qualityGate = qualityGate; + if (goal.blockerSignature !== undefined) + entry.blockerSignature = goal.blockerSignature; + if (goal.blockerOccurrenceCount !== undefined) + entry.blockerOccurrenceCount = goal.blockerOccurrenceCount; + if (goal.requiredExternalDecision !== undefined) + entry.requiredExternalDecision = goal.requiredExternalDecision; + return entry; +} +export async function checkpointUlwLoop(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, args.goalId); + if (args.status === "complete") + requireAllCriteriaPass(goal); + const evidence = nonEmptyEvidence(args.evidence); + const now = iso(); + let aggregateCompletion; + let qualityGate; + let codexGoal; + if (args.status === "complete") { + const aggregate = codexGoalMode(plan) === "aggregate"; + const final = isFinalRunCompletionCandidate(plan, goal); + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: aggregate ? (final ? ["complete"] : ["active"]) : ["complete"], requireSnapshot: true, requireComplete: !aggregate || final }); + codexGoal = reconciliation.snapshot.raw; + if (!reconciliation.ok) { + const objective = snapshot?.objective; + const mismatchedTaskObjective = snapshot?.available === true && objective !== undefined && normalizeObjective(objective) !== normalizeObjective(expectedCodexObjective(plan, goal)); + const completedTaskScoped = mismatchedTaskObjective && snapshot.status === "complete" && await canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence, scope); + const activeFinalTaskScoped = mismatchedTaskObjective && snapshot.status === "active" && await canReconcileActiveFinalTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence, scope); + const taskScoped = completedTaskScoped || activeFinalTaskScoped; + if (!taskScoped) + throw new UlwLoopError(`${formatCodexGoalReconciliation(reconciliation)}${aggregate && snapshot?.status === "complete" && objective !== undefined ? buildTaskScopedAggregateReconciliationHint(goal, final) : ""}`, "ulw_loop_codex_snapshot_mismatch"); + aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + } + if (final) + aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + if (final || aggregateCompletion !== undefined) + qualityGate = validateQualityGate(await readJsonInput(args.qualityGateJson, repoRoot)); + goal.status = "complete"; + goal.completedAt = now; + goal.evidence = evidence; + delete goal.failedAt; + delete goal.failureReason; + clearGoalBlockerFields(goal); + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; + } + else + applyBlockedOrFailed(goal, plan, args.status, evidence, now); + goal.updatedAt = now; + if (aggregateCompletion !== undefined) + plan.aggregateCompletion = aggregateCompletion; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + const ledgerEntry = buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion); + await appendLedger(repoRoot, ledgerEntry, scope); + return aggregateCompletion === undefined ? { plan, goal, ledgerEntry } : { plan, goal, ledgerEntry, aggregateCompletion }; + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts new file mode 100644 index 0000000..ee898de --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts @@ -0,0 +1,17 @@ +type RecordEvidenceCliArgs = { + readonly goalId: string; + readonly criterionId: string; + readonly status: "pass" | "fail" | "blocked"; + readonly evidence: string; + readonly notes?: string; +}; +export declare function hasFlag(argv: readonly string[], flag: string): boolean; +export declare function readValue(argv: readonly string[], flag: string): string | undefined; +export declare function readRepeated(argv: readonly string[], flag: string): string[]; +export declare function parseGoalArg(argv: readonly string[]): string | undefined; +export declare function readStdin(): Promise; +export declare function positionalText(argv: readonly string[]): string; +export declare function readJsonInput(value: string | undefined): Promise; +export declare function parseCodexGoalJson(value: string | undefined): Promise; +export declare function parseRecordEvidenceArgs(argv: readonly string[]): RecordEvidenceCliArgs; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js new file mode 100644 index 0000000..6e1eeb3 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js @@ -0,0 +1,97 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { readFile } from "node:fs/promises"; +import { UlwLoopError } from "./types.js"; +const VALUE_FLAGS = new Set("--brief --brief-file --session-id --codex-goal-mode --goal --goal-id --criterion-id --status --evidence --notes --codex-goal-json --quality-gate-json --kind --rationale --title --objective --target-goal-id --source --after-json --directive-json --directive-file --idempotency-key".split(" ")); +const SUBCOMMANDS = new Set("create-goals status complete-goals criteria record-evidence checkpoint steer add-goal record-review-blockers".split(" ")); +export function hasFlag(argv, flag) { return argv.includes(flag); } +export function readValue(argv, flag) { + const index = argv.indexOf(flag); + if (index >= 0) { + const next = argv[index + 1]; + return next === undefined || next.startsWith("--") ? undefined : next; + } + const prefix = `${flag}=`; + return argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} +export function readRepeated(argv, flag) { + const values = []; + const prefix = `${flag}=`; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === flag && next !== undefined && !next.startsWith("--")) { + values.push(next); + index += 1; + } + else if (arg?.startsWith(prefix)) + values.push(arg.slice(prefix.length)); + } + return values; +} +export function parseGoalArg(argv) { return readValue(argv, "--goal-id") ?? readValue(argv, "--goal"); } +export async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} +export function positionalText(argv) { + const words = []; + for (let index = SUBCOMMANDS.has(argv[0] ?? "") ? 1 : 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) + continue; + if (VALUE_FLAGS.has(arg)) { + index += 1; + continue; + } + if (arg.startsWith("--")) + continue; + words.push(arg); + } + return words.join(" ").trim(); +} +function looksLikeJson(value) { const trimmed = value.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } +export async function readJsonInput(value) { + if (value === undefined) + return undefined; + try { + return JSON.parse(looksLikeJson(value) ? value : await readFile(value, "utf8")); + } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid JSON input: ${message}`, "ULW_LOOP_JSON_INPUT_INVALID", { cause: error }); + } +} +export async function parseCodexGoalJson(value) { + if (value === undefined) + return undefined; + const raw = looksLikeJson(value) ? value : await readFile(value, "utf8"); + try { + JSON.parse(raw); + return raw; + } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid --codex-goal-json: ${message}`, "ULW_LOOP_CODEX_GOAL_JSON_INVALID", { cause: error }); + } +} +function required(argv, flag, code) { + const value = readValue(argv, flag)?.trim(); + if (value) + return value; + throw new UlwLoopError(`Missing ${flag}.`, code, { details: { flag } }); +} +function evidenceStatus(value) { + switch (value) { + case "pass": return "pass"; + case "fail": return "fail"; + case "blocked": return "blocked"; + default: throw new UlwLoopError("Invalid --status; expected pass, fail, or blocked.", "ULW_LOOP_EVIDENCE_STATUS_INVALID", { details: { status: value } }); + } +} +export function parseRecordEvidenceArgs(argv) { + const result = { goalId: required(argv, "--goal-id", "ULW_LOOP_GOAL_ID_REQUIRED"), criterionId: required(argv, "--criterion-id", "ULW_LOOP_CRITERION_ID_REQUIRED"), status: evidenceStatus(required(argv, "--status", "ULW_LOOP_EVIDENCE_STATUS_REQUIRED")), evidence: required(argv, "--evidence", "ULW_LOOP_EVIDENCE_REQUIRED") }; + const notes = readValue(argv, "--notes")?.trim(); + return notes ? { ...result, notes } : result; +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts b/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts new file mode 100644 index 0000000..ba876bf --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts @@ -0,0 +1 @@ +export declare function ulwLoopCommand(argv: readonly string[]): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/cli-commands.js b/plugins/omo/components/ulw-loop/dist/cli-commands.js new file mode 100644 index 0000000..6396a0c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-commands.js @@ -0,0 +1,175 @@ +// biome-ignore-all format: keep cli-commands dispatcher under the 200 pure LOC budget. +import { readFile } from "node:fs/promises"; +import { checkpointUlwLoop } from "./checkpoint.js"; +import { hasFlag, parseCodexGoalJson, parseRecordEvidenceArgs, positionalText, readStdin, readValue } from "./cli-arg-parser.js"; +import { blockedDecisionHandoff, normalizeCodexGoalMode, printJson, printStatus, ULW_LOOP_HELP } from "./cli-output.js"; +import { parseSteeringProposal, printSteerResult } from "./cli-steering.js"; +import { buildCodexGoalInstruction } from "./codex-goal-instruction.js"; +import { recordEvidence } from "./evidence.js"; +import { resolveUlwLoopSessionIdFromEnv } from "./paths.js"; +import { addUlwLoopGoal, createUlwLoopPlan, startNextUlwLoop, summarizeUlwLoopPlan } from "./plan-crud.js"; +import { readUlwLoopPlan } from "./plan-io.js"; +import { recordFinalReviewBlockers } from "./review-blockers.js"; +import { steerUlwLoop } from "./steering.js"; +import { UlwLoopError } from "./types.js"; +export async function ulwLoopCommand(argv) { + const command = argv[0] ?? "help"; + const rest = argv.slice(1); + const repoRoot = process.cwd(); + const json = hasFlag(rest, "--json"); + const scope = commandScope(rest); + try { + switch (command) { + case "help": + case "--help": + case "-h": + process.stdout.write(`${ULW_LOOP_HELP}\n`); + return 0; + case "create-goals": return await createGoals(repoRoot, rest, json, scope); + case "status": return await status(repoRoot, json, scope); + case "complete-goals": return await completeGoals(repoRoot, rest, json, scope); + case "checkpoint": return await checkpoint(repoRoot, rest, json, scope); + case "steer": return await steer(repoRoot, rest, json, scope); + case "add-goal": return await addGoal(repoRoot, rest, json, scope); + case "criteria": return await criteria(repoRoot, rest, json, scope); + case "record-evidence": return await captureEvidence(repoRoot, rest, json, scope); + case "record-review-blockers": return await reviewBlockers(repoRoot, rest, json, scope); + default: + process.stdout.write(`${ULW_LOOP_HELP}\n`); + return 1; + } + } + catch (error) { + if (error instanceof UlwLoopError) + process.stderr.write(`[ulw-loop] ${error.message}\n`); + else if (error instanceof Error) + process.stderr.write(`[ulw-loop] unexpected: ${error.message}\n`); + else + process.stderr.write("[ulw-loop] unknown error\n"); + return 1; + } +} +function commandScope(argv) { + const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv(); + return sessionId === null ? undefined : { sessionId }; +} +async function createGoals(repoRoot, argv, json, scope) { + const briefFile = readValue(argv, "--brief-file"); + const brief = readValue(argv, "--brief") ?? (briefFile === undefined ? undefined : await readFile(briefFile, "utf8")) ?? (hasFlag(argv, "--from-stdin") ? await readStdin() : undefined) ?? positionalText(argv); + if (!brief.trim()) + throw new UlwLoopError("Missing brief text. Pass --brief, --brief-file, --from-stdin, or positional text.", "ULW_LOOP_BRIEF_REQUIRED"); + const plan = await createUlwLoopPlan(repoRoot, { brief, codexGoalMode: normalizeCodexGoalMode(readValue(argv, "--codex-goal-mode")), force: hasFlag(argv, "--force") }, scope); + if (json) + printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else + process.stdout.write(`ulw-loop plan created: ${plan.goals.length} goal(s)\nbrief: ${plan.briefPath}\ngoals: ${plan.goalsPath}\nledger: ${plan.ledgerPath}\n`); + return 0; +} +async function status(repoRoot, json, scope) { + const plan = await readUlwLoopPlan(repoRoot, scope); + if (json) + printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else + printStatus(plan); + return 0; +} +async function completeGoals(repoRoot, argv, json, scope) { + const result = await startNextUlwLoop(repoRoot, { retryFailed: hasFlag(argv, "--retry-failed") }, scope); + if ("done" in result) { + const handoff = blockedDecisionHandoff(result.plan); + if (json) + printJson({ ok: true, done: true, blocked: handoff.length > 0, handoff, summary: summarizeUlwLoopPlan(result.plan), plan: result.plan }); + else + process.stdout.write(`${handoff || "ulw-loop: all goals complete"}\n`); + return 0; + } + const instruction = buildCodexGoalInstruction({ plan: result.plan, goal: result.goal }); + if (json) + printJson({ ok: true, resumed: result.resumed, goal: result.goal, instruction, plan: result.plan }); + else + process.stdout.write(`${instruction.text}\n`); + return 0; +} +async function checkpoint(repoRoot, argv, json, scope) { + const goalId = required(argv, "--goal-id"); + const statusValue = checkpointStatus(required(argv, "--status")); + const evidence = required(argv, "--evidence"); + const codexGoalJson = await parseCodexGoalJson(statusValue === "complete" ? required(argv, "--codex-goal-json") : readValue(argv, "--codex-goal-json")); + if (statusValue === "complete" && codexGoalJson === undefined) + throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const qualityGateJson = readValue(argv, "--quality-gate-json"); + const args = { + goalId, + status: statusValue, + evidence, + ...(codexGoalJson === undefined ? {} : { codexGoalJson }), + ...(qualityGateJson === undefined ? {} : { qualityGateJson }), + }; + const result = await checkpointUlwLoop(repoRoot, args, scope); + if (json) + printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop checkpoint: ${result.goal.id} -> ${result.goal.status}\n`); + return 0; +} +async function steer(repoRoot, argv, json, scope) { + const proposal = await parseSteeringProposal(argv); + const result = await steerUlwLoop(repoRoot, proposal, scope); + printSteerResult(result, json); + return result.accepted ? 0 : 1; +} +async function addGoal(repoRoot, argv, json, scope) { + const result = await addUlwLoopGoal(repoRoot, { title: required(argv, "--title"), objective: required(argv, "--objective") }, scope); + if (json) + printJson({ ok: true, plan: result.plan, goal: result.goal, summary: summarizeUlwLoopPlan(result.plan) }); + else { + process.stdout.write(`ulw-loop added goal: ${result.goal.id}\n`); + printStatus(result.plan); + } + return 0; +} +async function criteria(repoRoot, argv, json, scope) { + const goalId = required(argv, "--goal-id"); + const goal = findGoal(await readUlwLoopPlan(repoRoot, scope), goalId); + if (json) + printJson({ ok: true, goalId: goal.id, criteria: goal.successCriteria }); + else + process.stdout.write(`criteria for ${goal.id}:\n${goal.successCriteria.map((c) => `- ${c.id} [${c.status}] (${c.userModel}) ${c.scenario} evidence: ${c.capturedEvidence ?? "pending"}`).join("\n")}\n`); + return 0; +} +async function captureEvidence(repoRoot, argv, json, scope) { + const result = await recordEvidence(repoRoot, parseRecordEvidenceArgs(argv), scope); + if (json) + printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop evidence recorded: ${result.goal.id}/${result.criterion.id} -> ${result.criterion.status}\n`); + return 0; +} +async function reviewBlockers(repoRoot, argv, json, scope) { + const codexGoalJson = await parseCodexGoalJson(required(argv, "--codex-goal-json")); + if (codexGoalJson === undefined) + throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const result = await recordFinalReviewBlockers(repoRoot, { goalId: required(argv, "--goal-id"), title: required(argv, "--title"), objective: required(argv, "--objective"), evidence: required(argv, "--evidence"), codexGoalJson }, scope); + if (json) + printJson({ ok: true, plan: result.plan, blockedGoal: result.blockedGoal, goal: result.newGoal, ledgerEntries: result.ledgerEntries, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop final review blockers recorded: ${result.blockedGoal.id} -> review_blocked; added ${result.newGoal.id}\n`); + return 0; +} +function required(argv, flag) { + const value = readValue(argv, flag)?.trim(); + if (value) + return value; + throw new UlwLoopError(`Missing ${flag}.`, "ULW_LOOP_ARGUMENT_MISSING", { details: { flag } }); +} +function checkpointStatus(value) { + if (value === "complete" || value === "failed" || value === "blocked") + return value; + throw new UlwLoopError("Missing or invalid --status; expected complete, failed, or blocked.", "ULW_LOOP_STATUS_INVALID", { details: { status: value } }); +} +function findGoal(plan, goalId) { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + if (goal !== undefined) + return goal; + throw new UlwLoopError(`Unknown ulw-loop id: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { details: { goalId } }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-output.d.ts b/plugins/omo/components/ulw-loop/dist/cli-output.d.ts new file mode 100644 index 0000000..f6e08f0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-output.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopCodexGoalMode, UlwLoopPlan } from "./types.js"; +export declare const ULW_LOOP_HELP = "Usage:\n omo ulw-loop create-goals --brief \"...\" [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json]\n omo ulw-loop status [--json]\n omo ulw-loop complete-goals [--retry-failed] [--json]\n omo ulw-loop criteria --goal-id [--json]\n omo ulw-loop record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence \"...\" [--notes \"...\"] [--json]\n omo ulw-loop checkpoint --goal-id --status complete|failed|blocked --evidence \"...\" --codex-goal-json <...> [--quality-gate-json <...>] [--json]\n omo ulw-loop steer --kind ... --evidence \"...\" --rationale \"...\" [--json]\n omo ulw-loop add-goal --title \"...\" --objective \"...\" [--json]\n omo ulw-loop record-review-blockers --goal-id --title \"...\" --objective \"...\" --evidence \"...\" --codex-goal-json <...> [--json]\n\nAll subcommands accept [--session-id ] to isolate state under .omo/ulw-loop//; without it, Codex session env is used when present."; +export declare function printJson(value: unknown): void; +export declare function printStatus(plan: UlwLoopPlan): void; +export declare function blockedDecisionHandoff(plan: UlwLoopPlan): string; +export declare function normalizeCodexGoalMode(value: string | undefined): UlwLoopCodexGoalMode; diff --git a/plugins/omo/components/ulw-loop/dist/cli-output.js b/plugins/omo/components/ulw-loop/dist/cli-output.js new file mode 100644 index 0000000..ddf5ac4 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-output.js @@ -0,0 +1,55 @@ +import { UlwLoopError } from "./types.js"; +export const ULW_LOOP_HELP = `Usage: + omo ulw-loop create-goals --brief "..." [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json] + omo ulw-loop status [--json] + omo ulw-loop complete-goals [--retry-failed] [--json] + omo ulw-loop criteria --goal-id [--json] + omo ulw-loop record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence "..." [--notes "..."] [--json] + omo ulw-loop checkpoint --goal-id --status complete|failed|blocked --evidence "..." --codex-goal-json <...> [--quality-gate-json <...>] [--json] + omo ulw-loop steer --kind ... --evidence "..." --rationale "..." [--json] + omo ulw-loop add-goal --title "..." --objective "..." [--json] + omo ulw-loop record-review-blockers --goal-id --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json] + +All subcommands accept [--session-id ] to isolate state under .omo/ulw-loop//; without it, Codex session env is used when present.`; +export function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} +function criteriaCounts(goal) { + let pass = 0; + for (const criterion of goal.successCriteria) + if (criterion.status === "pass") + pass += 1; + return { pass, total: goal.successCriteria.length }; +} +export function printStatus(plan) { + let totalCriteria = 0; + let passCriteria = 0; + const lines = ["ulw-loop status", "", "goals:"]; + for (const goal of plan.goals) { + const counts = criteriaCounts(goal); + totalCriteria += counts.total; + passCriteria += counts.pass; + const marker = goal.id === plan.activeGoalId ? "*" : "-"; + lines.push(`${marker} ${goal.id} [${goal.status}] ${goal.title} (criteria: ${counts.pass}/${counts.total})`); + } + lines.push("", "summary:", `total goals: ${plan.goals.length}`, `criteria: ${passCriteria}/${totalCriteria} pass`); + process.stdout.write(`${lines.join("\n")}\n`); +} +export function blockedDecisionHandoff(plan) { + const blocked = plan.goals.find((goal) => goal.status === "needs_user_decision" && goal.nonRetriable); + if (blocked === undefined) + return ""; + return [ + "ulw-loop: blocked on repeated external authorization; no retryable failed goals remain.", + `Goal: ${blocked.id} - ${blocked.title}`, + `Required external decision: ${blocked.requiredExternalDecision ?? "provide the missing authorization or choose a different unblock path"}.`, + "Do not run complete-goals --retry-failed again until external state changes or the user authorizes an unblock path.", + ].join("\n"); +} +export function normalizeCodexGoalMode(value) { + if (value === undefined) + return "aggregate"; + if (value === "aggregate" || value === "per_story") + return value; + throw new UlwLoopError("Invalid --codex-goal-mode; expected aggregate or per_story.", "ULW_LOOP_CODEX_GOAL_MODE_INVALID", { details: { value } }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts b/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts new file mode 100644 index 0000000..13cc108 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts @@ -0,0 +1,12 @@ +import type { SteerUlwLoopResult, UlwLoopSteeringMutationKind, UlwLoopSteeringProposal, UlwLoopSteeringSource, UlwLoopSuccessCriterionUserModel } from "./types.js"; +export type CliSteeringProposal = UlwLoopSteeringProposal & { + readonly goalId?: string; + readonly scenario?: string; + readonly expectedEvidence?: string; + readonly userModel?: UlwLoopSuccessCriterionUserModel; +}; +export declare function parseSteeringKind(argv: readonly string[]): UlwLoopSteeringMutationKind; +export declare function parseSteeringSource(argv: readonly string[]): UlwLoopSteeringSource; +export declare function parseSteeringProposal(argv: readonly string[]): Promise; +export declare function normalizeSteeringProposal(proposal: CliSteeringProposal): CliSteeringProposal; +export declare function printSteerResult(result: SteerUlwLoopResult, json: boolean): void; diff --git a/plugins/omo/components/ulw-loop/dist/cli-steering.js b/plugins/omo/components/ulw-loop/dist/cli-steering.js new file mode 100644 index 0000000..9eae536 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-steering.js @@ -0,0 +1,145 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { parseGoalArg, readJsonInput, readValue } from "./cli-arg-parser.js"; +import { printJson, printStatus } from "./cli-output.js"; +import { ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS, UlwLoopError } from "./types.js"; +const SOURCES = ["user_prompt_submit", "finding", "cli"]; +function isKind(value) { return value !== undefined && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); } +function isSource(value) { return value !== undefined && SOURCES.some((source) => source === value); } +function isModel(value) { return ULW_LOOP_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); } +function fail(message, code, details) { throw new UlwLoopError(message, code, { details }); } +function text(value, field) { if (value === undefined) + return undefined; const trimmed = value.trim(); if (trimmed.length > 0) + return trimmed; return fail(`Empty ${field}.`, "ULW_LOOP_STEERING_FIELD_EMPTY", { field }); } +function required(argv, flag) { const value = text(readValue(argv, flag), flag); return value ?? fail(`Missing ${flag}.`, "ULW_LOOP_STEERING_FIELD_REQUIRED", { flag }); } +function requiredGoal(argv) { const value = text(parseGoalArg(argv), "--goal-id"); return value ?? fail("Missing --goal-id.", "ULW_LOOP_GOAL_ID_REQUIRED", { flag: "--goal-id" }); } +function readObject(value, key) { return Object.entries(value).find(([name]) => name === key)?.[1]; } +function isPlain(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } +function objectText(value, key) { const candidate = readObject(value, key); return typeof candidate === "string" ? candidate : undefined; } +export function parseSteeringKind(argv) { + const value = readValue(argv, "--kind"); + if (isKind(value)) + return value; + return value === undefined ? fail("Missing --kind.", "ULW_LOOP_STEERING_KIND_REQUIRED", { flag: "--kind" }) : fail(`Invalid --kind: ${value}.`, "ULW_LOOP_STEERING_KIND_INVALID", { value, expected: ULW_LOOP_STEERING_MUTATION_KINDS }); +} +export function parseSteeringSource(argv) { + const value = readValue(argv, "--source"); + if (value === undefined) + return "cli"; + return isSource(value) ? value : fail(`Invalid --source: ${value}.`, "ULW_LOOP_STEERING_SOURCE_INVALID", { value, expected: SOURCES }); +} +function child(value) { + if (!isPlain(value)) + return null; + const title = text(objectText(value, "title"), "title"); + const objective = text(objectText(value, "objective"), "objective"); + if (title === undefined || objective === undefined) + return null; + return { title, objective }; +} +async function children(argv, flag, needed) { + const input = needed ? required(argv, flag) : text(readValue(argv, flag), flag); + if (input === undefined) + return []; + const raw = await readJsonInput(input); + if (!Array.isArray(raw)) + return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const parsed = []; + for (const item of raw) { + const next = child(item); + if (next === null) + return fail(`${flag} entries require title/objective.`, "ULW_LOOP_STEERING_CHILD_INVALID", { flag }); + parsed.push(next); + } + return parsed; +} +async function stringArray(argv, flag) { + const raw = await readJsonInput(required(argv, flag)); + if (!Array.isArray(raw)) + return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const values = []; + for (const item of raw) { + if (typeof item !== "string") + return fail(`${flag} entries must be strings.`, "ULW_LOOP_STEERING_STRING_ARRAY_REQUIRED", { flag }); + values.push(text(item, flag) ?? ""); + } + return values; +} +function model(value) { const trimmed = text(value, "--user-model"); if (trimmed === undefined) + return undefined; return isModel(trimmed) ? trimmed : fail(`Invalid --user-model: ${trimmed}.`, "ULW_LOOP_STEERING_USER_MODEL_INVALID", { value: trimmed, expected: ULW_LOOP_SUCCESS_CRITERION_USER_MODELS }); } +function neverKind(kind) { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULW_LOOP_STEERING_KIND_UNSUPPORTED", { kind }); } +export async function parseSteeringProposal(argv) { + const kind = parseSteeringKind(argv); + const source = parseSteeringSource(argv); + const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") }; + switch (kind) { + case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") }); + case "split_subgoal": { + const goalId = requiredGoal(argv); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, childGoals: await children(argv, "--children", true) }); + } + case "reorder_pending": return normalizeSteeringProposal({ ...base, pendingOrder: await stringArray(argv, "--order") }); + case "revise_pending_wording": { + const goalId = requiredGoal(argv); + const revisedTitle = readValue(argv, "--title"); + const revisedObjective = readValue(argv, "--objective"); + if (revisedTitle === undefined && revisedObjective === undefined) + return fail("revise_pending_wording requires --title or --objective.", "ULW_LOOP_STEERING_UPDATE_REQUIRED", { kind }); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }) }); + } + case "revise_criterion": { + const goalId = requiredGoal(argv); + const criterionId = required(argv, "--criterion-id"); + const scenario = readValue(argv, "--scenario"); + const expectedEvidence = readValue(argv, "--expected-evidence"); + const userModel = model(readValue(argv, "--user-model")); + if (scenario === undefined && expectedEvidence === undefined && userModel === undefined) + return fail("revise_criterion requires scenario, expected-evidence, or user-model.", "ULW_LOOP_STEERING_UPDATE_REQUIRED", { kind }); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, criterionId, ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(userModel === undefined ? {} : { userModel }) }); + } + case "annotate_ledger": return normalizeSteeringProposal(base); + case "mark_blocked_superseded": { + const goalId = requiredGoal(argv); + const childGoals = await children(argv, "--replacements", false); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(childGoals.length === 0 ? {} : { childGoals }) }); + } + default: return neverKind(kind); + } +} +function normalizedChildren(values) { if (values === undefined) + return undefined; return values.map((item) => ({ title: text(item.title, "child.title") ?? "", objective: text(item.objective, "child.objective") ?? "" })); } +function normalizedStrings(values, field) { if (values === undefined) + return undefined; return values.map((value) => text(value, field) ?? ""); } +export function normalizeSteeringProposal(proposal) { + const evidence = text(proposal.evidence, "evidence") ?? ""; + const rationale = text(proposal.rationale, "rationale") ?? ""; + const goalId = text(proposal.goalId, "goalId"); + const targetGoalId = text(proposal.targetGoalId, "targetGoalId"); + const targetGoalIds = normalizedStrings(proposal.targetGoalIds, "targetGoalIds"); + const criterionId = text(proposal.criterionId, "criterionId"); + const title = text(proposal.title, "title"); + const objective = text(proposal.objective, "objective"); + const revisedTitle = text(proposal.revisedTitle, "revisedTitle"); + const revisedObjective = text(proposal.revisedObjective, "revisedObjective"); + const blockedReason = text(proposal.blockedReason, "blockedReason"); + const directiveText = text(proposal.directiveText, "directiveText"); + const promptSignature = text(proposal.promptSignature, "promptSignature"); + const idempotencyKey = text(proposal.idempotencyKey, "idempotencyKey"); + const scenario = text(proposal.scenario, "scenario"); + const expectedEvidence = text(proposal.expectedEvidence, "expectedEvidence"); + const childGoals = normalizedChildren(proposal.childGoals); + const pendingOrder = normalizedStrings(proposal.pendingOrder, "pendingOrder"); + return { kind: proposal.kind, source: proposal.source, evidence, rationale, ...(goalId === undefined ? {} : { goalId }), ...(targetGoalId === undefined ? {} : { targetGoalId }), ...(targetGoalIds === undefined ? {} : { targetGoalIds }), ...(criterionId === undefined ? {} : { criterionId }), ...(title === undefined ? {} : { title }), ...(objective === undefined ? {} : { objective }), ...(childGoals === undefined ? {} : { childGoals }), ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }), ...(pendingOrder === undefined ? {} : { pendingOrder }), ...(blockedReason === undefined ? {} : { blockedReason }), ...(proposal.after === undefined ? {} : { after: proposal.after }), ...(directiveText === undefined ? {} : { directiveText }), ...(promptSignature === undefined ? {} : { promptSignature }), ...(idempotencyKey === undefined ? {} : { idempotencyKey }), ...(proposal.now === undefined ? {} : { now: proposal.now }), ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(proposal.userModel === undefined ? {} : { userModel: proposal.userModel }) }; +} +export function printSteerResult(result, json) { + if (json) { + printJson({ ok: result.accepted, accepted: result.accepted, rejectedReasons: result.rejectedReasons, deduped: result.deduped, audit: result.audit, plan: result.plan }); + return; + } + const outcome = result.deduped ? "deduped" : result.accepted ? "accepted" : "rejected"; + process.stdout.write(`ulw-loop steer: ${outcome} ${result.audit.kind}\n`); + if (result.rejectedReasons.length > 0) + process.stdout.write(`rejected: ${result.rejectedReasons.join("; ")}\n`); + if (result.audit.idempotencyKey !== undefined) + process.stdout.write(`idempotency-key: ${result.audit.idempotencyKey}\n`); + printStatus(result.plan); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli.d.ts b/plugins/omo/components/ulw-loop/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/cli.js b/plugins/omo/components/ulw-loop/dist/cli.js new file mode 100644 index 0000000..475b8a6 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { ulwLoopCommand } from "./cli-commands.js"; +import { runPreToolUseGoalBudgetGuardCli, runUlwLoopHookCli } from "./codex-hook.js"; +const TOP_LEVEL_HELP = "Usage:\n omo ulw-loop [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ulw-loop help` for ulw-loop subcommands.\n"; +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "ulw-loop") + return ulwLoopCommand(argv.slice(1)); + if (command === "hook") { + const sub = argv[1]; + if (sub === "user-prompt-submit") { + await runUlwLoopHookCli(process.stdin, process.stdout); + return 0; + } + if (sub === "pre-tool-use") { + await runPreToolUseGoalBudgetGuardCli(process.stdin, process.stdout); + return 0; + } + process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`); + return 1; + } + process.stderr.write(`[omo] unknown command: ${command}\n${TOP_LEVEL_HELP}`); + return 1; +} +main() + .then((code) => { + process.exit(code); +}) + .catch((error) => { + process.stderr.write(`[omo] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts new file mode 100644 index 0000000..01f5388 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts @@ -0,0 +1,13 @@ +import type { UlwLoopItem, UlwLoopPlan } from "./types.js"; +export interface CodexCreateGoalPayload { + readonly objective: string; +} +export interface UlwLoopGoalInstruction { + readonly text: string; + readonly json: CodexCreateGoalPayload; +} +export declare function buildCodexGoalInstruction(args: { + readonly plan: UlwLoopPlan; + readonly goal: UlwLoopItem; + readonly isFinal?: boolean; +}): UlwLoopGoalInstruction; diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js new file mode 100644 index 0000000..0c50640 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js @@ -0,0 +1,100 @@ +import { codexGoalMode, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +export function buildCodexGoalInstruction(args) { + const mode = codexGoalMode(args.plan); + const createGoal = buildCreateGoalPayload(args.plan, args.goal); + const isFinal = args.isFinal ?? isFinalRunCompletionCandidate(args.plan, args.goal); + return { text: buildText(mode, args.plan, args.goal, createGoal, isFinal), json: createGoal }; +} +function buildCreateGoalPayload(plan, goal) { + return { objective: expectedCodexObjective(plan, goal) }; +} +function buildText(mode, plan, goal, createGoal, isFinal) { + return joinLines([ + mode === "aggregate" ? "UlwLoop aggregate-goal handoff" : "UlwLoop active-goal handoff", + `Mode: ${mode}`, + `Plan: ${plan.goalsPath}`, + `Ledger: ${plan.ledgerPath}`, + `Goal: ${goal.id} — ${goal.title}`, + "", + ...activeGoalLines(goal), + "", + ...successCriteriaLines(goal.successCriteria), + "", + "Codex goal integration constraints:", + "- Use the create_goal payload exactly as rendered: objective only.", + "- Goals are unlimited. Do not add numeric limits.", + ...modeConstraintLines(mode, isFinal), + finalSection(plan, goal, isFinal, mode === "aggregate"), + ...checkpointLines(plan, mode), + "", + "create_goal payload:", + JSON.stringify(createGoal, null, 2), + ]); +} +function modeConstraintLines(mode, isFinal) { + if (mode === "per_story") { + return [ + "- First call get_goal. If no active goal exists, call create_goal with the payload below.", + "- If a different active Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + "- Work only this goal until its completion audit passes.", + ]; + } + return [ + "- Codex goal = the whole omo ulw-loop run; OMO G001/G002/etc. = ledger stories.", + "- First call get_goal. If no active goal exists, call create_goal with the aggregate payload below.", + "- If get_goal reports the same aggregate objective as active, continue this OMO story without creating a new Codex goal.", + "- If a different active or incomplete Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + isFinal + ? "- This is the final story; update_goal is allowed only after the mandatory quality gate passes." + : "- This is not the final story: do not call update_goal yet; the aggregate Codex goal must remain active while later OMO stories remain.", + ]; +} +function checkpointLines(plan, mode) { + const failureLine = `- If blocked or failed, checkpoint with --status failed and the failure evidence; rerun complete-goals${sessionOption(plan)} --retry-failed to resume.`; + if (mode === "per_story") + return [failureLine]; + return [ + "- Checkpoint this OMO story with a fresh get_goal snapshot whose objective matches the aggregate payload.", + failureLine, + ]; +} +function activeGoalLines(goal) { + return ["Active goal:", `- id: ${goal.id}`, `- title: ${goal.title}`, `- objective: ${goal.objective}`]; +} +function successCriteriaLines(criteria) { + if (criteria.length === 0) + return ["Success criteria:", "- No success criteria recorded for this goal."]; + return ["Success criteria:", ...criteria.map(formatCriterionLine)]; +} +function formatCriterionLine(criterion) { + const remainingWork = criterion.status === "pending" ? " remaining work:" : ""; + return `-${remainingWork} [${criterion.id}] (${criterion.userModel}) ${criterion.scenario} — expect: ${criterion.expectedEvidence} — status: ${criterion.status}`; +} +function finalSection(plan, goal, isFinal, aggregate) { + if (!isFinal) + return "- This is not the final ulw-loop story; do not run the final ai-slop-cleaner/code-review gate yet."; + const option = sessionOption(plan); + const blockerCommand = `omo ulw-loop record-review-blockers${option} --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "" --evidence "" --codex-goal-json ""`; + const checkpointCommand = `omo ulw-loop checkpoint${option} --goal-id ${goal.id} --status complete --evidence "" --codex-goal-json "" --quality-gate-json ""`; + return joinLines([ + "Final story — run mandatory quality gate before update_goal:", + "- Run ai-slop-cleaner on changed files even when it is a no-op, rerun verification, then run the code review (spawn_agent(agent_type=\"codex-ultrawork-reviewer\", fork_turns=\"none\", ...); fall back to agent_type=\"worker\" with a scoped reviewer assignment if unavailable).", + "- If the final review is not APPROVE with architect status CLEAR, do not call update_goal. Record blocker work first:", + ` ${blockerCommand}`, + aggregate + ? '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:' + : '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:', + ` ${checkpointCommand}`, + ]); +} +function sessionOption(plan) { + const prefix = ".omo/ulw-loop/"; + const suffix = "/goals.json"; + if (!plan.goalsPath.startsWith(prefix) || !plan.goalsPath.endsWith(suffix)) + return ""; + const sessionId = plan.goalsPath.slice(prefix.length, -suffix.length); + return sessionId.length === 0 ? "" : ` --session-id ${sessionId}`; +} +function joinLines(lines) { + return lines.join("\n"); +} diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts new file mode 100644 index 0000000..2ee79e2 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts @@ -0,0 +1,26 @@ +export type CodexGoalSnapshotStatus = "active" | "complete" | "cancelled" | "failed" | "unknown"; +export interface CodexGoalSnapshot { + available: boolean; + objective?: string; + status?: CodexGoalSnapshotStatus; + raw: unknown; +} +export interface CodexGoalReconciliation { + ok: boolean; + snapshot: CodexGoalSnapshot; + warnings: string[]; + errors: string[]; +} +export interface ReconcileCodexGoalOptions { + expectedObjective: string; + acceptedObjectives?: readonly string[]; + allowedStatuses?: readonly CodexGoalSnapshotStatus[]; + requireSnapshot?: boolean; + requireComplete?: boolean; +} +export declare class CodexGoalSnapshotError extends Error { +} +export declare function parseCodexGoalSnapshot(value: unknown): CodexGoalSnapshot; +export declare function readCodexGoalSnapshotInput(raw: string | undefined, cwd?: string): Promise; +export declare function reconcileCodexGoalSnapshot(snapshot: CodexGoalSnapshot | null | undefined, options: ReconcileCodexGoalOptions): CodexGoalReconciliation; +export declare function formatCodexGoalReconciliation(reconciliation: CodexGoalReconciliation): string; diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js new file mode 100644 index 0000000..a630f22 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js @@ -0,0 +1,97 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +export class CodexGoalSnapshotError extends Error { +} +function safeObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} +function safeString(value) { + return typeof value === "string" ? value.trim() : ""; +} +function normalizeStatus(value) { + const status = safeString(value).toLowerCase(); + if (status === "complete" || status === "completed" || status === "done") + return "complete"; + if (status === "cancelled" || status === "canceled") + return "cancelled"; + if (status === "failed" || status === "failure") + return "failed"; + if (status === "active" || status === "in_progress" || status === "pending" || status === "running") + return "active"; + return "unknown"; +} +function normalizeObjective(value) { + return value.replace(/\s+/g, " ").trim(); +} +export function parseCodexGoalSnapshot(value) { + const root = safeObject(value); + const goalValue = Object.hasOwn(root, "goal") ? root["goal"] : value; + if (goalValue === null || goalValue === undefined || goalValue === false) { + return { available: false, raw: value }; + } + const goal = safeObject(goalValue); + const objective = safeString(goal["objective"] ?? goal["goal"] ?? goal["description"] ?? root["objective"]); + const status = normalizeStatus(goal["status"] ?? root["status"]); + return { + available: Boolean(objective || status !== "unknown"), + ...(objective ? { objective } : {}), + status, + raw: value, + }; +} +export async function readCodexGoalSnapshotInput(raw, cwd = process.cwd()) { + if (!raw?.trim()) + return null; + const trimmed = raw.trim(); + try { + return parseCodexGoalSnapshot(JSON.parse(trimmed)); + } + catch { + const path = resolve(cwd, trimmed); + if (!existsSync(path)) { + throw new CodexGoalSnapshotError(`Codex goal snapshot is neither valid JSON nor a readable path: ${trimmed}`); + } + try { + return parseCodexGoalSnapshot(JSON.parse(await readFile(path, "utf-8"))); + } + catch (error) { + throw new CodexGoalSnapshotError(`Codex goal snapshot path does not contain valid JSON: ${trimmed}${error instanceof Error ? ` (${error.message})` : ""}`); + } + } +} +export function reconcileCodexGoalSnapshot(snapshot, options) { + const effectiveSnapshot = snapshot ?? { available: false, raw: null }; + const errors = []; + const warnings = []; + if (!effectiveSnapshot.available) { + const message = "Codex goal snapshot is absent or reports no active goal; call get_goal and pass its JSON with --codex-goal-json."; + if (options.requireSnapshot) + errors.push(message); + else + warnings.push(message); + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; + } + const expected = normalizeObjective(options.expectedObjective); + const accepted = new Set([expected, ...(options.acceptedObjectives ?? []).map((objective) => normalizeObjective(objective))].filter(Boolean)); + const actual = normalizeObjective(effectiveSnapshot.objective ?? ""); + if (!actual) { + errors.push("Codex goal snapshot is missing objective text."); + } + else if (!accepted.has(actual)) { + errors.push(`Codex goal objective mismatch: expected "${expected}", got "${actual}".`); + } + const allowed = options.allowedStatuses ?? (options.requireComplete ? ["complete"] : ["active", "complete"]); + const actualStatus = effectiveSnapshot.status ?? "unknown"; + if (!allowed.includes(actualStatus)) { + errors.push(`Codex goal status mismatch: expected ${allowed.join(" or ")}, got ${actualStatus}.`); + } + if (options.requireComplete && actualStatus !== "complete") { + errors.push('Codex goal is not complete; call update_goal({status: "complete"}) only after the objective is actually complete, then pass the fresh get_goal JSON.'); + } + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; +} +export function formatCodexGoalReconciliation(reconciliation) { + const parts = [...reconciliation.errors, ...reconciliation.warnings]; + return parts.join(" "); +} diff --git a/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts b/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts new file mode 100644 index 0000000..f2b0333 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts @@ -0,0 +1,28 @@ +export interface UserPromptSubmitPayload { + readonly cwd: string; + readonly hook_event_name: "UserPromptSubmit"; + readonly model?: string; + readonly permission_mode?: string; + readonly prompt: string; + readonly session_id: string; + readonly transcript_path?: string; + readonly turn_id?: string; +} +export interface PreToolUsePayload { + readonly cwd: string; + readonly hook_event_name: "PreToolUse"; + readonly model: string; + readonly permission_mode: string; + readonly session_id: string; + readonly tool_input: unknown; + readonly tool_name: string; + readonly tool_use_id: string; + readonly transcript_path: string | null; + readonly turn_id: string; +} +export declare function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null; +export declare function parsePreToolUsePayload(raw: string): PreToolUsePayload | null; +export declare function applyUserPromptUlwLoopSteering(payload: UserPromptSubmitPayload): Promise; +export declare function applyPreToolUseGoalBudgetGuard(payload: PreToolUsePayload): string; +export declare function runUlwLoopHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise; +export declare function runPreToolUseGoalBudgetGuardCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/codex-hook.js b/plugins/omo/components/ulw-loop/dist/codex-hook.js new file mode 100644 index 0000000..70d6916 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-hook.js @@ -0,0 +1,145 @@ +import { parseUlwLoopSteeringDirective, steerUlwLoop } from "./steering.js"; +const CREATE_GOAL_TOOL_NAME = "create_goal"; +const CREATE_GOAL_PAYLOAD_WARNING = "Use create_goal with objective only. Omit token_budget so the goal stays unlimited, and put lifecycle status changes on update_goal."; +export function parseUserPromptSubmitPayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isUserPromptSubmitPayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function parsePreToolUsePayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPreToolUsePayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export async function applyUserPromptUlwLoopSteering(payload) { + try { + if (payload.hook_event_name !== "UserPromptSubmit") + return ""; + const proposal = parseUlwLoopSteeringDirective(payload.prompt); + if (proposal === null) + return ""; + const result = await steerUlwLoop(payload.cwd, proposal, payloadScope(payload)); + if (!result.accepted) + return ""; + return JSON.stringify({ + status: "accepted", + kind: result.audit.kind, + source: result.audit.source, + deduped: result.deduped, + }); + } + catch (error) { + if (error instanceof Error) + return ""; + return ""; + } +} +function payloadScope(payload) { + return { sessionId: payload.session_id }; +} +export function applyPreToolUseGoalBudgetGuard(payload) { + if (payload.hook_event_name !== "PreToolUse") + return ""; + if (payload.tool_name !== CREATE_GOAL_TOOL_NAME) + return ""; + if (!hasInvalidCreateGoalInput(payload.tool_input)) + return ""; + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: CREATE_GOAL_PAYLOAD_WARNING, + additionalContext: CREATE_GOAL_PAYLOAD_WARNING, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export async function runUlwLoopHookCli(stdin, stdout) { + try { + const payload = parseUserPromptSubmitPayload(await readAll(stdin)); + if (payload === null) + return; + const output = await applyUserPromptUlwLoopSteering(payload); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +export async function runPreToolUseGoalBudgetGuardCli(stdin, stdout) { + try { + const payload = parsePreToolUsePayload(await readAll(stdin)); + if (payload === null) + return; + const output = applyPreToolUseGoalBudgetGuard(payload); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +function isUserPromptSubmitPayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "UserPromptSubmit" && + typeof value["cwd"] === "string" && + typeof value["prompt"] === "string" && + typeof value["session_id"] === "string" && + ["model", "permission_mode", "transcript_path", "turn_id"].every((key) => optionalString(value[key]))); +} +function isPreToolUsePayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || typeof value["transcript_path"] === "string") && + typeof value["turn_id"] === "string" && + Object.hasOwn(value, "tool_input")); +} +function hasInvalidCreateGoalInput(value) { + return isRecord(value) && Object.keys(value).some((key) => key !== "objective"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function optionalString(value) { + return value === undefined || typeof value === "string"; +} +function readAll(stdin) { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/command-types.d.ts b/plugins/omo/components/ulw-loop/dist/command-types.d.ts new file mode 100644 index 0000000..5f6970b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/command-types.d.ts @@ -0,0 +1,34 @@ +import type { UlwLoopCodexGoalMode, UlwLoopStatus } from "./constants.js"; +export interface CreateUlwLoopOptions { + brief: string; + goals?: readonly { + readonly title?: string; + readonly objective: string; + }[]; + codexGoalMode?: UlwLoopCodexGoalMode; + now?: Date; + force?: boolean; +} +export interface StartNextOptions { + now?: Date; + retryFailed?: boolean; +} +export interface CheckpointOptions { + goalId: string; + status: Extract | "blocked"; + evidence?: string; + codexGoal?: unknown; + qualityGate?: unknown; + allowActiveFinalCodexGoal?: boolean; + now?: Date; +} +export interface AddUlwLoopGoalOptions { + title: string; + objective: string; + evidence?: string; + now?: Date; +} +export interface RecordFinalReviewBlockersOptions extends AddUlwLoopGoalOptions { + goalId: string; + codexGoal?: unknown; +} diff --git a/plugins/omo/components/ulw-loop/dist/command-types.js b/plugins/omo/components/ulw-loop/dist/command-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/command-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/constants.d.ts b/plugins/omo/components/ulw-loop/dist/constants.d.ts new file mode 100644 index 0000000..99909fd --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/constants.d.ts @@ -0,0 +1,16 @@ +export declare const ULW_LOOP_DIR = ".omo/ulw-loop"; +export declare const ULW_LOOP_BRIEF = "brief.md"; +export declare const ULW_LOOP_GOALS = "goals.json"; +export declare const ULW_LOOP_LEDGER = "ledger.jsonl"; +export type UlwLoopStatus = "pending" | "in_progress" | "complete" | "failed" | "blocked" | "review_blocked" | "needs_user_decision"; +export type UlwLoopCodexGoalMode = "aggregate" | "per_story"; +export type UlwLoopSteeringStatus = "superseded" | "blocked"; +export declare const ULW_LOOP_STEERING_MUTATION_KINDS: readonly ["add_subgoal", "split_subgoal", "reorder_pending", "revise_pending_wording", "revise_criterion", "annotate_ledger", "mark_blocked_superseded"]; +export type UlwLoopSteeringMutationKind = (typeof ULW_LOOP_STEERING_MUTATION_KINDS)[number]; +export type UlwLoopSteeringSource = "user_prompt_submit" | "finding" | "cli"; +export declare const ULW_LOOP_SUCCESS_CRITERION_USER_MODELS: readonly ["happy", "edge", "regression", "adversarial"]; +export type UlwLoopSuccessCriterionUserModel = (typeof ULW_LOOP_SUCCESS_CRITERION_USER_MODELS)[number]; +export declare const ULW_LOOP_CRITERION_STATUSES: readonly ["pending", "pass", "fail", "blocked"]; +export type UlwLoopCriterionStatus = (typeof ULW_LOOP_CRITERION_STATUSES)[number]; +export declare const ULW_LOOP_LEDGER_EVENT_KINDS: readonly ["plan_created", "goal_started", "goal_resumed", "goal_completed", "goal_blocked", "goal_failed", "goal_needs_user_decision", "goal_retried", "aggregate_completed", "aggregate_objective_migrated", "goal_added", "steering_accepted", "steering_rejected", "final_review_failed", "goal_review_blocked", "evidence_captured", "criterion_failed", "criterion_blocked", "criteria_revised"]; +export type UlwLoopLedgerEventKind = (typeof ULW_LOOP_LEDGER_EVENT_KINDS)[number]; diff --git a/plugins/omo/components/ulw-loop/dist/constants.js b/plugins/omo/components/ulw-loop/dist/constants.js new file mode 100644 index 0000000..022e31c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/constants.js @@ -0,0 +1,41 @@ +export const ULW_LOOP_DIR = ".omo/ulw-loop"; +export const ULW_LOOP_BRIEF = "brief.md"; +export const ULW_LOOP_GOALS = "goals.json"; +export const ULW_LOOP_LEDGER = "ledger.jsonl"; +export const ULW_LOOP_STEERING_MUTATION_KINDS = [ + "add_subgoal", + "split_subgoal", + "reorder_pending", + "revise_pending_wording", + "revise_criterion", + "annotate_ledger", + "mark_blocked_superseded", +]; +export const ULW_LOOP_SUCCESS_CRITERION_USER_MODELS = [ + "happy", + "edge", + "regression", + "adversarial", +]; +export const ULW_LOOP_CRITERION_STATUSES = ["pending", "pass", "fail", "blocked"]; +export const ULW_LOOP_LEDGER_EVENT_KINDS = [ + "plan_created", + "goal_started", + "goal_resumed", + "goal_completed", + "goal_blocked", + "goal_failed", + "goal_needs_user_decision", + "goal_retried", + "aggregate_completed", + "aggregate_objective_migrated", + "goal_added", + "steering_accepted", + "steering_rejected", + "final_review_failed", + "goal_review_blocked", + "evidence_captured", + "criterion_failed", + "criterion_blocked", + "criteria_revised", +]; diff --git a/plugins/omo/components/ulw-loop/dist/domain-types.d.ts b/plugins/omo/components/ulw-loop/dist/domain-types.d.ts new file mode 100644 index 0000000..bfa805d --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/domain-types.d.ts @@ -0,0 +1,95 @@ +import type { UlwLoopCodexGoalMode, UlwLoopCriterionStatus, UlwLoopLedgerEventKind, UlwLoopStatus, UlwLoopSteeringMutationKind, UlwLoopSteeringStatus, UlwLoopSuccessCriterionUserModel } from "./constants.js"; +import type { UlwLoopSteeringAudit } from "./steering-types.js"; +export interface UlwLoopSuccessCriterion { + readonly id: string; + readonly scenario: string; + readonly userModel: UlwLoopSuccessCriterionUserModel; + readonly expectedEvidence: string; + capturedEvidence: string | null; + status: UlwLoopCriterionStatus; + capturedAt?: string; + notes?: string; +} +export interface UlwLoopItem { + id: string; + title: string; + objective: string; + status: UlwLoopStatus; + successCriteria: UlwLoopSuccessCriterion[]; + attempt: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + reviewBlockedAt?: string; + evidence?: string; + failureReason?: string; + steeringStatus?: UlwLoopSteeringStatus; + supersededBy?: string[]; + supersedes?: string[]; + blockedReason?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; + nonRetriable?: boolean; + steeringEvidence?: string; + steeringRationale?: string; +} +export interface UlwLoopAggregateCompletion { + status: "complete"; + completedAt: string; + evidence: string; + codexGoal?: unknown; +} +export interface UlwLoopPlan { + version: 1; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + codexGoalMode?: UlwLoopCodexGoalMode; + codexObjective?: string; + codexObjectiveAliases?: string[]; + aggregateCompletion?: UlwLoopAggregateCompletion; + activeGoalId?: string; + goals: UlwLoopItem[]; +} +export interface UlwLoopQualityGate { + aiSlopCleaner: { + status: "passed"; + evidence: string; + }; + verification: { + status: "passed"; + commands: string[]; + evidence: string; + }; + codeReview: { + recommendation: "APPROVE"; + architectStatus: "CLEAR"; + evidence: string; + }; +} +export interface UlwLoopLedgerEntry { + at: string; + kind: UlwLoopLedgerEventKind; + goalId?: string; + criterionId?: string; + status?: UlwLoopStatus; + criterionStatus?: UlwLoopCriterionStatus; + message?: string; + codexGoal?: unknown; + evidence?: string; + capturedEvidence?: string; + qualityGate?: UlwLoopQualityGate; + steering?: UlwLoopSteeringAudit; + before?: unknown; + after?: unknown; + mutationKind?: UlwLoopSteeringMutationKind; + idempotencyKey?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; +} diff --git a/plugins/omo/components/ulw-loop/dist/domain-types.js b/plugins/omo/components/ulw-loop/dist/domain-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/domain-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/evidence.d.ts b/plugins/omo/components/ulw-loop/dist/evidence.d.ts new file mode 100644 index 0000000..5d25273 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/evidence.d.ts @@ -0,0 +1,31 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +type EvidenceStatus = "pass" | "fail" | "blocked"; +type RecordEvidenceArgs = { + readonly goalId: string; + readonly criterionId: string; + readonly status: EvidenceStatus; + readonly evidence: string; + readonly notes?: string; +}; +export declare function recordEvidence(repoRoot: string, args: RecordEvidenceArgs, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; + criterion: UlwLoopSuccessCriterion; + ledgerEntry: UlwLoopLedgerEntry; +}>; +export declare function markCriteriaPendingResetForGoal(repoRoot: string, goalId: string, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + resetCount: number; +}>; +export declare function criteriaSummary(plan: UlwLoopPlan): { + totalCriteria: number; + passCount: number; + pendingCount: number; + failCount: number; + blockedCount: number; + goalsWithUnresolvedCriteria: string[]; +}; +export declare function unresolvedCriteriaOf(goal: UlwLoopItem): UlwLoopSuccessCriterion[]; +export declare function requireAllCriteriaPass(goal: UlwLoopItem): void; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/evidence.js b/plugins/omo/components/ulw-loop/dist/evidence.js new file mode 100644 index 0000000..6cd75be --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/evidence.js @@ -0,0 +1,119 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { hasAllCriteriaPass } from "./goal-status.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +function ulwLoopFail(message, code, details) { throw new UlwLoopError(message, code, { details }); } +function ledgerKind(status) { + switch (status) { + case "pass": + return "evidence_captured"; + case "fail": + return "criterion_failed"; + case "blocked": + return "criterion_blocked"; + default: + return ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status }); + } +} +function findGoal(plan, goalId) { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + return goal ?? ulwLoopFail(`UlwLoop goal not found: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { goalId }); +} +function findCriterion(goal, criterionId) { + const criterion = goal.successCriteria.find((candidate) => candidate.id === criterionId); + return criterion ?? ulwLoopFail(`Success criterion not found: ${criterionId}.`, "ULW_LOOP_CRITERION_NOT_FOUND", { goalId: goal.id, criterionId }); +} +function nonEmptyEvidence(evidence) { const trimmed = evidence.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ULW_LOOP_EVIDENCE_REQUIRED", {}); } +export async function recordEvidence(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, args.goalId); + const criterion = findCriterion(goal, args.criterionId); + const evidence = nonEmptyEvidence(args.evidence); + const kind = ledgerKind(args.status); + const prevStatus = criterion.status; + const capturedAt = iso(); + criterion.status = args.status; + criterion.capturedEvidence = evidence; + criterion.capturedAt = capturedAt; + if (args.notes !== undefined) + criterion.notes = args.notes; + goal.updatedAt = capturedAt; + plan.updatedAt = capturedAt; + await writePlan(repoRoot, plan, scope); + const ledgerEntry = { + at: capturedAt, + kind, + goalId: goal.id, + criterionId: criterion.id, + criterionStatus: args.status, + evidence, + capturedEvidence: evidence, + before: { status: prevStatus }, + after: { goalId: goal.id, criterionId: criterion.id, status: args.status, evidence, capturedAt, prevStatus }, + }; + await appendLedger(repoRoot, ledgerEntry, scope); + return { plan, goal, criterion, ledgerEntry }; + }); +} +export async function markCriteriaPendingResetForGoal(repoRoot, goalId, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, goalId); + const now = iso(); + const before = goal.successCriteria.map((criterion) => ({ id: criterion.id, status: criterion.status, capturedEvidence: criterion.capturedEvidence, capturedAt: criterion.capturedAt ?? null })); + for (const criterion of goal.successCriteria) { + criterion.status = "pending"; + criterion.capturedEvidence = null; + delete criterion.capturedAt; + delete criterion.notes; + } + goal.updatedAt = now; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "criteria_revised", goalId, message: `Reset ${goal.successCriteria.length} criteria to pending.`, before, after: { resetCount: goal.successCriteria.length } }, scope); + return { plan, resetCount: goal.successCriteria.length }; + }); +} +export function criteriaSummary(plan) { + let totalCriteria = 0; + let passCount = 0; + let pendingCount = 0; + let failCount = 0; + let blockedCount = 0; + const goalsWithUnresolvedCriteria = []; + for (const goal of plan.goals) { + let unresolved = false; + for (const criterion of goal.successCriteria) { + totalCriteria += 1; + if (criterion.status !== "pass") + unresolved = true; + switch (criterion.status) { + case "pass": + passCount += 1; + break; + case "pending": + pendingCount += 1; + break; + case "fail": + failCount += 1; + break; + case "blocked": + blockedCount += 1; + break; + default: ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status: criterion.status }); + } + } + if (unresolved) + goalsWithUnresolvedCriteria.push(goal.id); + } + return { totalCriteria, passCount, pendingCount, failCount, blockedCount, goalsWithUnresolvedCriteria }; +} +export function unresolvedCriteriaOf(goal) { return goal.successCriteria.filter((criterion) => criterion.status !== "pass"); } +export function requireAllCriteriaPass(goal) { + if (hasAllCriteriaPass(goal)) + return; + throw new UlwLoopError(`Goal ${goal.id} has unresolved success criteria.`, "ulw_loop_criteria_not_all_pass", { + details: { goalId: goal.id, unresolved: unresolvedCriteriaOf(goal).map((criterion) => ({ id: criterion.id, status: criterion.status })) }, + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/goal-status.d.ts b/plugins/omo/components/ulw-loop/dist/goal-status.d.ts new file mode 100644 index 0000000..3f18043 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/goal-status.d.ts @@ -0,0 +1,12 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +export declare const ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE: string; +export declare function aggregateCodexObjectiveForScope(scope?: UlwLoopScope): string; +export declare function codexGoalMode(plan: UlwLoopPlan): UlwLoopCodexGoalMode; +export declare function isUlwLoopDone(plan: UlwLoopPlan): boolean; +export declare function isFinalRunCompletionCandidate(plan: UlwLoopPlan, goal: UlwLoopItem): boolean; +export declare function aggregateCodexObjective(plan: UlwLoopPlan): string; +export declare function expectedCodexObjective(plan: UlwLoopPlan, goal: UlwLoopItem): string; +export declare function compatibleCodexObjectives(plan: UlwLoopPlan): readonly string[]; +export declare function hasAllCriteriaPass(goal: UlwLoopItem): boolean; +export declare function firstUnresolvedCriterion(goal: UlwLoopItem): UlwLoopSuccessCriterion | undefined; diff --git a/plugins/omo/components/ulw-loop/dist/goal-status.js b/plugins/omo/components/ulw-loop/dist/goal-status.js new file mode 100644 index 0000000..e8b5708 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/goal-status.js @@ -0,0 +1,69 @@ +import { ulwLoopGoalsRelativePath, ulwLoopLedgerRelativePath } from "./paths.js"; +export const ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE = aggregateCodexObjectiveForScope(); +export function aggregateCodexObjectiveForScope(scope) { + return `Complete the durable ulw-loop plan in ${ulwLoopGoalsRelativePath(scope)}, including later accepted/appended stories, under the original brief constraints; use ${ulwLoopLedgerRelativePath(scope)} as the audit trail.`; +} +export function codexGoalMode(plan) { + return plan.codexGoalMode ?? "per_story"; +} +function isResolvedStatus(status) { + return status === "complete"; +} +function isSupersededResolved(goal, plan) { + if (goal.steeringStatus !== "superseded") + return false; + const replacements = goal.supersededBy ?? []; + if (replacements.length === 0) + return false; + return replacements.every((id) => { + const replacement = plan.goals.find((candidate) => candidate.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); +} +function isCompletionBlocking(goal, plan) { + if (goal.steeringStatus === "superseded") + return !isSupersededResolved(goal, plan); + if (goal.steeringStatus === "blocked") + return true; + return !isResolvedStatus(goal.status); +} +function isCompletionBlockingForFinalCandidate(candidate, finalCandidate, plan) { + if (candidate.id === finalCandidate.id) + return false; + if (candidate.steeringStatus === "superseded") { + const replacements = candidate.supersededBy ?? []; + if (replacements.length === 0) + return true; + return !replacements.every((id) => { + if (id === finalCandidate.id) + return true; + const replacement = plan.goals.find((goal) => goal.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); + } + return isCompletionBlocking(candidate, plan); +} +export function isUlwLoopDone(plan) { + if (plan.aggregateCompletion?.status === "complete") + return true; + return plan.goals.every((goal) => !isCompletionBlocking(goal, plan)); +} +export function isFinalRunCompletionCandidate(plan, goal) { + return (isCompletionBlocking(goal, plan) && + plan.goals.every((candidate) => !isCompletionBlockingForFinalCandidate(candidate, goal, plan))); +} +export function aggregateCodexObjective(plan) { + return plan.codexObjective ?? ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE; +} +export function expectedCodexObjective(plan, goal) { + return codexGoalMode(plan) === "aggregate" ? aggregateCodexObjective(plan) : goal.objective; +} +export function compatibleCodexObjectives(plan) { + return [aggregateCodexObjective(plan), ...(plan.codexObjectiveAliases ?? [])]; +} +export function hasAllCriteriaPass(goal) { + return goal.successCriteria.length > 0 && goal.successCriteria.every((criterion) => criterion.status === "pass"); +} +export function firstUnresolvedCriterion(goal) { + return goal.successCriteria.find((criterion) => criterion.status !== "pass"); +} diff --git a/plugins/omo/components/ulw-loop/dist/paths.d.ts b/plugins/omo/components/ulw-loop/dist/paths.d.ts new file mode 100644 index 0000000..7c28841 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/paths.d.ts @@ -0,0 +1,16 @@ +export interface UlwLoopScope { + readonly sessionId?: string | null; +} +type EnvMap = Readonly>; +export declare function normalizeUlwLoopSessionId(sessionId: string | null | undefined): string | null; +export declare function resolveUlwLoopSessionIdFromEnv(env?: EnvMap): string | null; +export declare function ulwLoopRelativeDir(scope?: UlwLoopScope): string; +export declare function ulwLoopDir(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopBriefRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopGoalsRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopLedgerRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopBriefPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopGoalsPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function repoRelative(absolutePath: string, repoRoot: string): string; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/paths.js b/plugins/omo/components/ulw-loop/dist/paths.js new file mode 100644 index 0000000..6ad99ac --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/paths.js @@ -0,0 +1,59 @@ +import { join } from "node:path"; +import { ULW_LOOP_BRIEF, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER } from "./types.js"; +const SESSION_ENV_KEYS = ["OMO_ULW_LOOP_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID"]; +export function normalizeUlwLoopSessionId(sessionId) { + const trimmed = sessionId?.trim(); + if (!trimmed) + return null; + const pathSegments = trimmed + .split(/[\\/]+/) + .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + const candidate = (pathSegments.length > 0 ? pathSegments.join("-") : trimmed) + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^\.+/, "") + .replace(/^[.-]+|[.-]+$/g, ""); + return candidate.length > 0 ? candidate : null; +} +export function resolveUlwLoopSessionIdFromEnv(env = process.env) { + for (const key of SESSION_ENV_KEYS) { + const normalized = normalizeUlwLoopSessionId(env[key]); + if (normalized !== null) + return normalized; + } + return null; +} +export function ulwLoopRelativeDir(scope) { + const sessionId = normalizeUlwLoopSessionId(scope?.sessionId); + return sessionId === null ? ULW_LOOP_DIR : `${ULW_LOOP_DIR}/${sessionId}`; +} +export function ulwLoopDir(repoRoot, scope) { + return join(repoRoot, ulwLoopRelativeDir(scope)); +} +export function ulwLoopBriefRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_BRIEF}`; +} +export function ulwLoopGoalsRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_GOALS}`; +} +export function ulwLoopLedgerRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_LEDGER}`; +} +export function ulwLoopBriefPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_BRIEF); +} +export function ulwLoopGoalsPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_GOALS); +} +export function ulwLoopLedgerPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER); +} +export function repoRelative(absolutePath, repoRoot) { + const slashPrefix = `${repoRoot}/`; + const backslashPrefix = `${repoRoot}\\`; + if (absolutePath.startsWith(slashPrefix)) + return absolutePath.slice(slashPrefix.length).split("\\").join("/"); + if (absolutePath.startsWith(backslashPrefix)) + return absolutePath.slice(backslashPrefix.length).split("\\").join("/"); + return absolutePath.split("\\").join("/"); +} diff --git a/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts b/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts new file mode 100644 index 0000000..b89e031 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts @@ -0,0 +1,48 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +export type UlwLoopPlanSummary = { + readonly total: number; + readonly pending: number; + readonly in_progress: number; + readonly complete: number; + readonly failed: number; + readonly blocked: number; + readonly review_blocked: number; + readonly needs_user_decision: number; + readonly superseded: number; + readonly criteria: { + readonly total: number; + readonly pass: number; + readonly pending: number; + readonly fail: number; + readonly blocked: number; + }; +}; +export declare function seedDefaultSuccessCriteria(goalIndex: number, objective: string): UlwLoopSuccessCriterion[]; +export declare function deriveGoalCandidates(brief: string): Array<{ + title: string; + objective: string; +}>; +export declare function createUlwLoopPlan(repoRoot: string, args: { + brief: string; + codexGoalMode?: UlwLoopCodexGoalMode; + force?: boolean; +}, scope?: UlwLoopScope): Promise; +export declare function addUlwLoopGoal(repoRoot: string, args: { + title: string; + objective: string; +}, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; +}>; +export declare function startNextUlwLoop(repoRoot: string, args?: { + retryFailed?: boolean; +}, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; + resumed: boolean; +} | { + done: true; + plan: UlwLoopPlan; +}>; +export declare function summarizeUlwLoopPlan(plan: UlwLoopPlan): UlwLoopPlanSummary; diff --git a/plugins/omo/components/ulw-loop/dist/plan-crud.js b/plugins/omo/components/ulw-loop/dist/plan-crud.js new file mode 100644 index 0000000..73d6a9c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-crud.js @@ -0,0 +1,119 @@ +// biome-ignore-all format: keep this port under the mandated pure LOC budget. +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { aggregateCodexObjectiveForScope, isUlwLoopDone } from "./goal-status.js"; +import { ulwLoopBriefPath, ulwLoopBriefRelativePath, ulwLoopDir, ulwLoopGoalsPath, ulwLoopGoalsRelativePath, ulwLoopLedgerPath, ulwLoopLedgerRelativePath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +function cleanLine(line) { return line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").trim(); } +function normalizeObjective(value) { return value.replace(/\s+/g, " ").trim(); } +function titleFromObjective(objective, fallback) { const firstLine = objective.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? fallback; return firstLine.length > 72 ? `${firstLine.slice(0, 69).trimEnd()}...` : firstLine; } +function normalizeGoalId(title, index) { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 36).replace(/-+$/g, ""); return `G${String(index + 1).padStart(3, "0")}${slug ? `-${slug}` : ""}`; } +function assertNonEmpty(value, label) { const trimmed = value?.trim(); if (!trimmed) + throw new UlwLoopError(`Missing ${label}.`, "ULW_LOOP_ARGUMENT_MISSING"); return trimmed; } +function truncateObjective(objective) { return objective.length > 80 ? `${objective.slice(0, 77).trimEnd()}...` : objective; } +export function seedDefaultSuccessCriteria(goalIndex, objective) { + const subject = truncateObjective(normalizeObjective(objective) || `Goal ${goalIndex + 1}`); + const rows = [ + ["C001", "happy", `happy path for: ${subject}`, `Replace via revise_criterion with observable happy-path proof for goal ${goalIndex + 1}.`], + ["C002", "edge", "edge case (boundary/empty/malformed)", `Replace via revise_criterion with boundary or malformed-input proof for: ${subject}.`], + ["C003", "regression", "regression: adjacent surface still works", `Replace via revise_criterion with regression proof for neighboring behavior after: ${subject}.`], + ]; + return rows.map(([id, userModel, scenario, expectedEvidence]) => ({ id, scenario, userModel, expectedEvidence, capturedEvidence: null, status: "pending" })); +} +export function deriveGoalCandidates(brief) { + const bulletGoals = brief.split(/\r?\n/).map((line) => ({ original: line, cleaned: normalizeObjective(cleanLine(line)) })).filter(({ cleaned }) => cleaned.length > 0 && cleaned.length <= 1200).filter(({ original, cleaned }, index, all) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(original) && all.findIndex((candidate) => candidate.cleaned === cleaned) === index).map(({ cleaned }) => cleaned); + const paragraphs = brief.split(/\n\s*\n/).map(normalizeObjective).filter((paragraph) => paragraph.length > 0 && !paragraph.startsWith("#")); + const selected = (bulletGoals.length > 0 ? bulletGoals : paragraphs).length > 0 ? (bulletGoals.length > 0 ? bulletGoals : paragraphs) : ["Complete the requested project objective."]; + return selected.map((objective, index) => ({ title: titleFromObjective(objective, `Goal ${index + 1}`), objective })); +} +function makeGoal(title, objective, index, now) { + const cleanTitle = assertNonEmpty(title, "title"); + const cleanObjective = assertNonEmpty(objective, "objective"); + return { id: normalizeGoalId(cleanTitle, index), title: cleanTitle, objective: cleanObjective, status: "pending", successCriteria: seedDefaultSuccessCriteria(index, cleanObjective), attempt: 0, createdAt: now, updatedAt: now }; +} +function appendGoalToPlan(plan, title, objective, now) { + const goal = makeGoal(title, objective, plan.goals.length, now); + plan.goals.push(goal); + plan.updatedAt = now; + return goal; +} +function isScheduleEligible(goal) { return goal.steeringStatus !== "superseded" && goal.steeringStatus !== "blocked"; } +function clearGoalBlockerFields(goal) { + for (const key of ["blockedReason", "blockerSignature", "blockerOccurrenceCount", "requiredExternalDecision", "nonRetriable", "failedAt", "failureReason"]) + delete goal[key]; +} +export async function createUlwLoopPlan(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + if (!args.force && existsSync(ulwLoopGoalsPath(repoRoot, scope))) { + const existing = await readUlwLoopPlan(repoRoot, scope); + if (isUlwLoopDone(existing)) + throw completedPlanExistsError(scope); + throw new UlwLoopError(`Refusing to overwrite existing ${ulwLoopGoalsRelativePath(scope)}; pass --force to recreate it.`, "ULW_LOOP_PLAN_EXISTS"); + } + const now = iso(); + const goals = deriveGoalCandidates(args.brief).map((goal, index) => makeGoal(goal.title, goal.objective, index, now)); + const plan = { version: 1, createdAt: now, updatedAt: now, briefPath: ulwLoopBriefRelativePath(scope), goalsPath: ulwLoopGoalsRelativePath(scope), ledgerPath: ulwLoopLedgerRelativePath(scope), codexGoalMode: args.codexGoalMode ?? "aggregate", goals }; + if (plan.codexGoalMode === "aggregate") + plan.codexObjective = aggregateCodexObjectiveForScope(scope); + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await writeFile(ulwLoopBriefPath(repoRoot, scope), args.brief.endsWith("\n") ? args.brief : `${args.brief}\n`, "utf8"); + await writePlan(repoRoot, plan, scope); + await writeFile(ulwLoopLedgerPath(repoRoot, scope), "", "utf8"); + await appendLedger(repoRoot, { at: now, kind: "plan_created", message: `${goals.length} goal(s) created` }, scope); + return plan; + }); +} +function completedPlanExistsError(scope) { + return new UlwLoopError([ + `Existing ulw-loop aggregate is already complete at ${ulwLoopGoalsRelativePath(scope)}.`, + "Start a new run with `omo ulw-loop create-goals --session-id ...` to isolate fresh state.", + "Use --force only when you intentionally want to overwrite the completed evidence.", + ].join(" "), "ULW_LOOP_PLAN_EXISTS_COMPLETE"); +} +export async function addUlwLoopGoal(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const now = iso(); + const goal = appendGoalToPlan(plan, args.title, args.objective, now); + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_added", goalId: goal.id, status: goal.status, message: goal.title }, scope); + return { plan, goal }; + }); +} +export async function startNextUlwLoop(repoRoot, args = {}, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const now = iso(); + if (plan.aggregateCompletion?.status === "complete") + return { done: true, plan }; + const existing = plan.goals.find((goal) => goal.status === "in_progress" && isScheduleEligible(goal)); + if (existing) { + await appendLedger(repoRoot, { at: now, kind: "goal_resumed", goalId: existing.id, status: existing.status, message: "Resuming active ulw-loop" }, scope); + return { plan, goal: existing, resumed: true }; + } + let next = plan.goals.find((goal) => goal.status === "pending" && isScheduleEligible(goal)); + if (!next && args.retryFailed) { + next = plan.goals.find((goal) => goal.status === "failed" && !goal.nonRetriable && isScheduleEligible(goal)); + if (next) + await appendLedger(repoRoot, { at: now, kind: "goal_retried", goalId: next.id, status: "pending", ...(next.failureReason ? { message: next.failureReason } : {}) }, scope); + } + if (!next) + return { done: true, plan }; + next.status = "in_progress"; + next.attempt += 1; + next.startedAt = now; + clearGoalBlockerFields(next); + next.updatedAt = now; + plan.activeGoalId = next.id; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_started", goalId: next.id, status: next.status, message: `Attempt ${next.attempt}` }, scope); + return { plan, goal: next, resumed: false }; + }); +} +export function summarizeUlwLoopPlan(plan) { + const countStatus = (status) => plan.goals.filter((goal) => goal.status === status).length; + const countCriteria = (status) => plan.goals.reduce((sum, goal) => sum + goal.successCriteria.filter((criterion) => criterion.status === status).length, 0); + return { total: plan.goals.length, pending: countStatus("pending"), in_progress: countStatus("in_progress"), complete: countStatus("complete"), failed: countStatus("failed"), blocked: countStatus("blocked"), review_blocked: countStatus("review_blocked"), needs_user_decision: countStatus("needs_user_decision"), superseded: plan.goals.filter((goal) => goal.steeringStatus === "superseded").length, criteria: { total: plan.goals.reduce((sum, goal) => sum + goal.successCriteria.length, 0), pass: countCriteria("pass"), pending: countCriteria("pending"), fail: countCriteria("fail"), blocked: countCriteria("blocked") } }; +} diff --git a/plugins/omo/components/ulw-loop/dist/plan-io.d.ts b/plugins/omo/components/ulw-loop/dist/plan-io.d.ts new file mode 100644 index 0000000..070fa6a --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-io.d.ts @@ -0,0 +1,8 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export declare function withUlwLoopMutationLock(repoRoot: string, fn: () => Promise): Promise; +export declare function withUlwLoopMutationLock(repoRoot: string, scope: UlwLoopScope | undefined, fn: () => Promise): Promise; +export declare function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): Promise; +export declare function writePlan(repoRoot: string, plan: UlwLoopPlan, scope?: UlwLoopScope): Promise; +export declare function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry, scope?: UlwLoopScope): Promise; +export declare function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/plan-io.js b/plugins/omo/components/ulw-loop/dist/plan-io.js new file mode 100644 index 0000000..eed44f7 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-io.js @@ -0,0 +1,89 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { aggregateCodexObjectiveForScope } from "./goal-status.js"; +import { repoRelative, ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath, ulwLoopRelativeDir, } from "./paths.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; +const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `; +const LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`; +const locks = new Map(); +function hasCode(error, code) { + return error instanceof Error && "code" in error && error.code === code; +} +function isLegacyEnumeratedAggregateObjective(objective) { + return objective === LEGACY_OBJECTIVE || Boolean(objective?.startsWith(LEGACY_OBJECTIVE_PREFIX)); +} +function isSteeringKind(value) { + return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised"; +} +export async function withUlwLoopMutationLock(repoRoot, scopeOrFn, maybeFn) { + const scope = typeof scopeOrFn === "function" ? undefined : scopeOrFn; + const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn; + if (fn === undefined) + throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING"); + const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`; + const prior = locks.get(lockKey) ?? Promise.resolve(); + const run = prior.then(fn, fn); + locks.set(lockKey, run.catch(() => undefined)); + return run; +} +export async function readUlwLoopPlan(repoRoot, scope) { + const path = ulwLoopGoalsPath(repoRoot, scope); + let raw; + try { + raw = await readFile(path, "utf8"); + } + catch (error) { + if (!hasCode(error, "ENOENT")) + throw error; + throw new UlwLoopError(`No ulw-loop plan found at ${repoRelative(path, repoRoot)}. Run \`omo ulw-loop create-goals ...\` first.`, "ULW_LOOP_PLAN_MISSING", { cause: error }); + } + const parsed = JSON.parse(raw); + if (parsed.version !== 1 || !Array.isArray(parsed.goals)) { + throw new UlwLoopError(`Invalid ulw-loop plan at ${repoRelative(path, repoRoot)}.`, "ULW_LOOP_PLAN_INVALID"); + } + const previousObjective = parsed.codexObjective; + if ((parsed.codexGoalMode ?? "per_story") === "aggregate" && + isLegacyEnumeratedAggregateObjective(previousObjective)) { + const now = iso(); + parsed.codexObjective = aggregateCodexObjectiveForScope(scope); + parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])]; + parsed.updatedAt = now; + await writePlan(repoRoot, parsed, scope); + await appendLedger(repoRoot, { + at: now, + kind: "aggregate_objective_migrated", + message: "Migrated legacy enumerated aggregate Codex objective to the stable pointer objective.", + before: { codexObjective: previousObjective }, + after: { codexObjective: parsed.codexObjective }, + }, scope); + } + return parsed; +} +export async function writePlan(repoRoot, plan, scope) { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + const path = ulwLoopGoalsPath(repoRoot, scope); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + await rename(tmpPath, path); +} +export async function appendLedger(repoRoot, entry, scope) { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}\n`, "utf8"); +} +export async function readSteeringLedgerEntries(repoRoot, scope) { + let raw; + try { + raw = await readFile(ulwLoopLedgerPath(repoRoot, scope), "utf8"); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return []; + throw error; + } + const entries = []; + for (const line of raw.split(/\r?\n/).filter(Boolean)) { + const entry = JSON.parse(line); + if (isSteeringKind(entry.kind)) + entries.push(entry); + } + return entries; +} diff --git a/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts b/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts new file mode 100644 index 0000000..838d18b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopItem, UlwLoopPlan, UlwLoopQualityGate } from "./types.js"; +export declare function validateQualityGate(input: unknown): UlwLoopQualityGate; +export declare function normalizeBlockerEvidence(evidence: string): string; +export declare function classifyExternalAuthorizationBlocker(evidence: string): string | null; +export declare function sameBlockerOccurrences(plan: UlwLoopPlan, signature: string): number; +export declare function clearGoalBlockerFields(goal: UlwLoopItem): void; diff --git a/plugins/omo/components/ulw-loop/dist/quality-gate.js b/plugins/omo/components/ulw-loop/dist/quality-gate.js new file mode 100644 index 0000000..3e5da31 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/quality-gate.js @@ -0,0 +1,123 @@ +import { UlwLoopError } from "./types.js"; +const BLOCKER_FIELD_KEYS = "blocker blockerSignature blockerEvidence blockerOccurrences blockedAt".split(" "); +const URL_PATTERN = /https?:\/\/\S+/g; +const PUNCTUATION_PATTERN = /[`"'()[\]{}:,;]/g; +const WHITESPACE_PATTERN = /\s+/g; +const AUTH_PATTERN = /\b(auth\w*|credential\w*|token|permission\w*|scope\w*|access|unauthorized|forbidden|401|403)\b/; +const MISSING_PATTERN = /\b(unset|missing|required|requires|without|omit\w*|not set|not available|no read packages|read packages)\b/; +const GHCR_PATTERN = /\b(ghcr|github container registry|read packages|imagepullsecret|package api|anonymous|container image)\b/; +const GHCR_401_PATTERN = /\b(401|unauthorized|anonymous pull|authentication required)\b/; +const GHCR_403_PATTERN = /\b(403|forbidden|read packages|package api)\b/; +const UNCONDITIONAL_APPROVAL_PATTERN = /\bUNCONDITIONAL\s+APPROVAL\b/i; +function invalid(message, field) { + throw new UlwLoopError(message, "ULW_LOOP_QUALITY_GATE_INVALID", { details: { field } }); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function section(value, field) { + return isRecord(value) ? value : invalid(`Final quality gate is missing ${field} evidence.`, field); +} +function nonEmptyString(value, field) { + return typeof value === "string" && value.trim() !== "" + ? value + : invalid(`Final quality gate requires non-empty ${field}.`, field); +} +function numberField(value, field) { + return typeof value === "number" && Number.isFinite(value) + ? value + : invalid(`Final quality gate requires numeric ${field}.`, field); +} +function stringArray(value, field) { + if (!Array.isArray(value) || value.length === 0) + return invalid(`Final quality gate requires ${field}.`, field); + return value.map((item) => nonEmptyString(item, field)); +} +function normalizeReviewerField({ value, field, expectedValue, evidenceApproved, }) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") { + if (evidenceApproved) + return expectedValue; + invalid(`${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`, field); + } + if (trimmed === expectedValue) + return expectedValue; + invalid(`${field} must be ${expectedValue}.`, field); + } + if (value === undefined) { + if (evidenceApproved) + return expectedValue; + invalid(`${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`, field); + } + invalid(`${field} must be ${expectedValue}.`, field); +} +export function validateQualityGate(input) { + const gate = section(input, "qualityGate"); + const cleaner = section(gate["aiSlopCleaner"], "aiSlopCleaner"); + const verification = section(gate["verification"], "verification"); + const review = section(gate["codeReview"], "codeReview"); + const coverage = section(gate["criteriaCoverage"], "criteriaCoverage"); + if (cleaner["status"] !== "passed") + invalid("aiSlopCleaner.status must be passed.", "aiSlopCleaner.status"); + if (verification["status"] !== "passed") + invalid("verification.status must be passed.", "verification.status"); + const totalCriteria = numberField(coverage["totalCriteria"], "criteriaCoverage.totalCriteria"); + const passCount = numberField(coverage["passCount"], "criteriaCoverage.passCount"); + if (passCount < totalCriteria) + invalid("criteriaCoverage.passCount must cover totalCriteria.", "criteriaCoverage.passCount"); + const commands = stringArray(verification["commands"], "verification.commands"); + const covered = stringArray(coverage["adversarialClassesCovered"], "criteriaCoverage.adversarialClassesCovered"); + const cleanerEvidence = nonEmptyString(cleaner["evidence"], "aiSlopCleaner.evidence"); + const verificationEvidence = nonEmptyString(verification["evidence"], "verification.evidence"); + const reviewEvidence = nonEmptyString(review["evidence"], "codeReview.evidence"); + const approvalEvidence = UNCONDITIONAL_APPROVAL_PATTERN.test(reviewEvidence); + const recommendation = normalizeReviewerField({ + value: review["recommendation"], + field: "codeReview.recommendation", + expectedValue: "APPROVE", + evidenceApproved: approvalEvidence, + }); + const architectStatus = normalizeReviewerField({ + value: review["architectStatus"], + field: "codeReview.architectStatus", + expectedValue: "CLEAR", + evidenceApproved: approvalEvidence, + }); + const result = { + aiSlopCleaner: { status: "passed", evidence: cleanerEvidence }, + verification: { status: "passed", commands, evidence: verificationEvidence }, + codeReview: { recommendation, architectStatus, evidence: reviewEvidence }, + }; + Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } }); + return result; +} +export function normalizeBlockerEvidence(evidence) { + const withoutUrls = evidence.toLowerCase().replace(URL_PATTERN, " "); + const withoutPunctuation = withoutUrls.replace(PUNCTUATION_PATTERN, " "); + return withoutPunctuation.replace(WHITESPACE_PATTERN, " ").trim(); +} +export function classifyExternalAuthorizationBlocker(evidence) { + const normalized = normalizeBlockerEvidence(evidence); + if (!normalized || !AUTH_PATTERN.test(normalized) || !MISSING_PATTERN.test(normalized)) + return null; + if (!GHCR_PATTERN.test(normalized)) + return "EXTERNAL_AUTHORIZATION_REQUIRED"; + const status401 = GHCR_401_PATTERN.test(normalized) ? "HTTP_401_ANONYMOUS" : null; + const status403 = GHCR_403_PATTERN.test(normalized) ? "HTTP_403_NO_READ_PACKAGES" : null; + const status = [status401, status403].filter((part) => part !== null).join("+"); + return `GHCR_PULL_ACCESS:${status || "AUTHORIZATION_REQUIRED"}:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED`; +} +function nestedBlockerSignature(goal) { + const blocker = Reflect.get(goal, "blocker"); + const signature = isRecord(blocker) ? blocker["signature"] : null; + return typeof signature === "string" ? signature : null; +} +export function sameBlockerOccurrences(plan, signature) { + return plan.goals.filter((goal) => goal.blockerSignature === signature || nestedBlockerSignature(goal) === signature) + .length; +} +export function clearGoalBlockerFields(goal) { + for (const key of BLOCKER_FIELD_KEYS) + Reflect.deleteProperty(goal, key); +} diff --git a/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts b/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts new file mode 100644 index 0000000..bbf59a3 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts @@ -0,0 +1,16 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export interface RecordFinalReviewBlockersArgs { + readonly goalId: string; + readonly title: string; + readonly objective: string; + readonly evidence: string; + readonly codexGoalJson: string; +} +export interface RecordFinalReviewBlockersResult { + readonly plan: UlwLoopPlan; + readonly blockedGoal: UlwLoopItem; + readonly newGoal: UlwLoopItem; + readonly ledgerEntries: UlwLoopLedgerEntry[]; +} +export declare function recordFinalReviewBlockers(repoRoot: string, args: RecordFinalReviewBlockersArgs, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/review-blockers.js b/plugins/omo/components/ulw-loop/dist/review-blockers.js new file mode 100644 index 0000000..2ea4a46 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/review-blockers.js @@ -0,0 +1,70 @@ +// biome-ignore-all format: compact port must stay within the requested pure LOC budget. +import { readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +const BLOCKER_FIELDS = "blockedReason blockerSignature blockerOccurrenceCount requiredExternalDecision nonRetriable failedAt failureReason completedAt blocker blockerEvidence blockerOccurrences blockedAt".split(" "); +function ulwLoopError(message, code) { + throw new UlwLoopError(message, code); +} +function nextGoalId(plan) { + const max = plan.goals.reduce((current, goal) => { + const digits = /^G(\d+)/u.exec(goal.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + 1).padStart(3, "0")}`; +} +function appendBlockerGoal(plan, args, now) { + const index = plan.goals.length; + const goal = { + id: nextGoalId(plan), + title: args.title, + objective: args.objective, + status: "pending", + successCriteria: seedDefaultSuccessCriteria(index, args.objective), + attempt: 0, + createdAt: now, + updatedAt: now, + }; + plan.goals.push(goal); + return goal; +} +export async function recordFinalReviewBlockers(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = plan.goals.find((candidate) => candidate.id === args.goalId); + if (goal === undefined) + ulwLoopError(`Unknown ulw-loop id: ${args.goalId}`, "ulw_loop_goal_not_found"); + if (goal.status !== "in_progress") + ulwLoopError(`${goal.id} is ${goal.status}.`, "ulw_loop_goal_not_in_progress"); + if (!isFinalRunCompletionCandidate(plan, goal)) + ulwLoopError(`${goal.id} is not final.`, "ulw_loop_not_final_story"); + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const aggregate = codexGoalMode(plan) === "aggregate"; + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: ["active"], requireSnapshot: true, requireComplete: false }); + if (!reconciliation.ok) + ulwLoopError(reconciliation.errors.join(" "), "ulw_loop_codex_snapshot_mismatch"); + const now = iso(); + for (const field of BLOCKER_FIELDS) + Reflect.deleteProperty(goal, field); + goal.status = "review_blocked"; + goal.reviewBlockedAt = now; + goal.evidence = args.evidence; + goal.updatedAt = now; + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; + const newGoal = appendBlockerGoal(plan, args, now); + plan.updatedAt = now; + const codexGoal = reconciliation.snapshot.raw; + const blockedEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal }; + const addedEntry = { at: now, kind: "goal_added", goalId: newGoal.id, status: newGoal.status, evidence: args.evidence, message: newGoal.title }; + const summaryEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal, message: `Review blockers recorded; appended ${newGoal.id}.` }; + Reflect.set(summaryEntry, "kind", "blocker_recorded"); + const ledgerEntries = [blockedEntry, addedEntry, summaryEntry]; + await writePlan(repoRoot, plan, scope); + for (const entry of ledgerEntries) + await appendLedger(repoRoot, entry, scope); + return { plan, blockedGoal: goal, newGoal, ledgerEntries }; + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/runtime.d.ts b/plugins/omo/components/ulw-loop/dist/runtime.d.ts new file mode 100644 index 0000000..41c9223 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/runtime.d.ts @@ -0,0 +1,10 @@ +export interface UlwLoopErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} +export declare class UlwLoopError extends Error { + readonly code: string; + readonly details?: Record; + constructor(message: string, code: string, opts?: UlwLoopErrorOptions); +} +export declare function iso(): string; diff --git a/plugins/omo/components/ulw-loop/dist/runtime.js b/plugins/omo/components/ulw-loop/dist/runtime.js new file mode 100644 index 0000000..811e8d1 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/runtime.js @@ -0,0 +1,13 @@ +export class UlwLoopError extends Error { + constructor(message, code, opts) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = "UlwLoopError"; + this.code = code; + if (opts?.details !== undefined) { + this.details = opts.details; + } + } +} +export function iso() { + return new Date().toISOString(); +} diff --git a/plugins/omo/components/ulw-loop/dist/steering-types.d.ts b/plugins/omo/components/ulw-loop/dist/steering-types.d.ts new file mode 100644 index 0000000..84533aa --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering-types.d.ts @@ -0,0 +1,63 @@ +import type { UlwLoopSteeringMutationKind, UlwLoopSteeringSource } from "./constants.js"; +import type { UlwLoopPlan } from "./domain-types.js"; +export interface UlwLoopSteeringInvariantResult { + accepted: boolean; + structuralInvariantAccepted: boolean; + evidenceBackedNecessity: boolean; + noEasierCompletion: boolean; + rejectedReasons: string[]; + reasons?: string[]; +} +export interface UlwLoopSteeringChildGoal { + title: string; + objective: string; +} +export interface UlwLoopSteeringAfterPayload { + title?: string; + objective?: string; + pendingGoalIds?: string[]; + children?: UlwLoopSteeringChildGoal[]; +} +export interface UlwLoopSteeringProposal { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalId?: string; + targetGoalIds?: string[]; + criterionId?: string; + evidence: string; + rationale: string; + title?: string; + objective?: string; + childGoals?: UlwLoopSteeringChildGoal[]; + revisedTitle?: string; + revisedObjective?: string; + pendingOrder?: string[]; + blockedReason?: string; + after?: UlwLoopSteeringAfterPayload; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + now?: Date; +} +export interface UlwLoopSteeringAudit { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalIds: string[]; + criterionId?: string; + before?: unknown; + after?: unknown; + evidence: string; + rationale: string; + invariant: UlwLoopSteeringInvariantResult; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + deduped?: boolean; +} +export interface SteerUlwLoopResult { + plan: UlwLoopPlan; + accepted: boolean; + audit: UlwLoopSteeringAudit; + rejectedReasons: string[]; + deduped: boolean; +} diff --git a/plugins/omo/components/ulw-loop/dist/steering-types.js b/plugins/omo/components/ulw-loop/dist/steering-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/steering.d.ts b/plugins/omo/components/ulw-loop/dist/steering.d.ts new file mode 100644 index 0000000..01a8a86 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { SteerUlwLoopResult, UlwLoopPlan, UlwLoopSteeringAudit, UlwLoopSteeringProposal } from "./types.js"; +export declare function validateUlwLoopSteeringProposal(plan: UlwLoopPlan, proposal: unknown): UlwLoopSteeringAudit; +export declare function applySteeringMutation(plan: UlwLoopPlan, proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAudit): UlwLoopPlan; +export declare function parseUlwLoopSteeringDirective(text: string): UlwLoopSteeringProposal | null; +export declare function steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringProposal, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/steering.js b/plugins/omo/components/ulw-loop/dist/steering.js new file mode 100644 index 0000000..8c5d75b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering.js @@ -0,0 +1,292 @@ +// biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget +import { isUlwLoopDone } from "./goal-status.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readSteeringLedgerEntries, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS } from "./types.js"; +const SOURCES = ["user_prompt_submit", "finding", "cli"]; +const PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]); +const isObject = (value) => typeof value === "object" && value !== null; +const isPlain = (value) => isObject(value) && !Array.isArray(value); +const read = (value, key) => Object.entries(value).find(([name]) => name === key)?.[1]; +const isText = (value) => typeof value === "string" && value.trim().length > 0; +const text = (value, key) => { + const candidate = read(value, key); + return isText(candidate) ? candidate.trim() : undefined; +}; +const isKind = (value) => typeof value === "string" && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); +const isSource = (value) => typeof value === "string" && SOURCES.some((source) => source === value); +const isModel = (value) => typeof value === "string" && ULW_LOOP_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); +const texts = (value, key) => { + const candidate = read(value, key); + return Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? candidate : []; +}; +function targets(proposal) { + const many = texts(proposal, "targetGoalIds"); + const one = text(proposal, "targetGoalId") ?? text(proposal, "goalId"); + return many.length > 0 ? many : one === undefined ? [] : [one]; +} +const after = (proposal) => { + const candidate = read(proposal, "after"); + return isPlain(candidate) ? candidate : undefined; +}; +const revised = (proposal, direct, nested) => text(proposal, direct) ?? text(after(proposal) ?? proposal, nested); +function child(value) { + if (!isPlain(value)) + return null; + const title = text(value, "title"); + const objective = text(value, "objective"); + if (title === undefined || objective === undefined) + return null; + return { title, objective }; +} +function childValues(proposal) { + const direct = read(proposal, "childGoals"); + if (Array.isArray(direct) && direct.length > 0) + return direct; + const nested = after(proposal); + const fromAfter = nested === undefined ? undefined : read(nested, "children"); + return Array.isArray(fromAfter) ? fromAfter : []; +} +const children = (proposal) => childValues(proposal).map(child).filter((item) => item !== null); +const pendingOrder = (proposal) => { + const direct = texts(proposal, "pendingOrder"); + return direct.length > 0 ? direct : texts(after(proposal) ?? proposal, "pendingGoalIds"); +}; +function hasProtected(value) { + if (!isObject(value)) + return false; + for (const [key, childValue] of Object.entries(value)) + if (PROTECTED.has(key) || key.toLowerCase().includes("complete") || hasProtected(childValue)) + return true; + return false; +} +function allText(value) { + if (typeof value === "string") + return value; + return isObject(value) ? Object.values(value).map(allText).filter(Boolean).join("\n") : ""; +} +function weakens(value) { + const valueText = allText(value).toLowerCase(); + return /\b(skip|bypass|weaken|remove|omit|auto[-\s]?complete|mark complete|complete faster)\b/.test(valueText) && /\b(test|tests|verification|review|quality gate|complete|completion)\b/.test(valueText); +} +function auditFor(proposal, reasons) { + const object = isPlain(proposal) ? proposal : undefined; + const kindRaw = object === undefined ? undefined : read(object, "kind"); + const sourceRaw = object === undefined ? undefined : read(object, "source"); + const evidence = object === undefined ? "" : (text(object, "evidence") ?? ""); + const rationale = object === undefined ? "" : (text(object, "rationale") ?? ""); + const audit = { kind: isKind(kindRaw) ? kindRaw : "annotate_ledger", source: isSource(sourceRaw) ? sourceRaw : "cli", targetGoalIds: object === undefined ? [] : targets(object), evidence, rationale, invariant: { accepted: reasons.length === 0, structuralInvariantAccepted: reasons.length === 0, evidenceBackedNecessity: evidence.length > 0 && rationale.length > 0, noEasierCompletion: !weakens(proposal), rejectedReasons: reasons, reasons } }; + if (object === undefined) + return audit; + const criterionId = text(object, "criterionId"); + const directiveText = text(object, "directiveText"); + const promptSignature = text(object, "promptSignature"); + const idempotencyKey = text(object, "idempotencyKey"); + if (criterionId !== undefined) + audit.criterionId = criterionId; + if (directiveText !== undefined) + audit.directiveText = directiveText; + if (promptSignature !== undefined) + audit.promptSignature = promptSignature; + if (idempotencyKey !== undefined) + audit.idempotencyKey = idempotencyKey; + return audit; +} +export function validateUlwLoopSteeringProposal(plan, proposal) { + const reasons = []; + if (!isPlain(proposal)) + reasons.push("proposal must be an object"); + const object = isPlain(proposal) ? proposal : {}; + const kind = read(object, "kind"); + if (!isKind(kind)) + reasons.push(`invalid kind: ${String(kind)}`); + if (!isSource(read(object, "source"))) + reasons.push(`invalid source: ${String(read(object, "source"))}`); + if (text(object, "evidence") === undefined) + reasons.push("missing evidence"); + if (text(object, "rationale") === undefined) + reasons.push("missing rationale"); + if (hasProtected(proposal)) + reasons.push("protected payload"); + if (weakens(proposal)) + reasons.push("weakened completion"); + if (isUlwLoopDone(plan)) + reasons.push("plan already complete"); + if (isKind(kind)) + validateKind(plan, object, kind, reasons); + return auditFor(proposal, reasons); +} +function goal(plan, id) { + return id === undefined ? undefined : plan.goals.find((item) => item.id === id); +} +function validateKind(plan, proposal, kind, reasons) { + const target = goal(plan, targets(proposal)[0]); + if (kind === "add_subgoal" && (text(proposal, "title") === undefined || text(proposal, "objective") === undefined)) + reasons.push("add_subgoal requires title/objective"); + if ((kind === "split_subgoal" || kind === "revise_pending_wording" || kind === "mark_blocked_superseded") && target === undefined) + reasons.push(`${kind} requires target`); + if ((kind === "split_subgoal" || kind === "revise_pending_wording") && target !== undefined && target.status !== "pending") + reasons.push(`${kind} requires pending target`); + const rawChildren = childValues(proposal); + if (kind === "split_subgoal" && rawChildren.length === 0) + reasons.push("split_subgoal requires children"); + if ((kind === "split_subgoal" || kind === "mark_blocked_superseded") && rawChildren.some((item) => child(item) === null)) + reasons.push(`${kind} children require title/objective`); + if (kind === "reorder_pending") + validateOrder(plan, proposal, reasons); + if (kind === "revise_pending_wording" && revised(proposal, "revisedTitle", "title") === undefined && revised(proposal, "revisedObjective", "objective") === undefined) + reasons.push("revise_pending_wording requires update"); + if (kind === "revise_criterion") + validateCriterion(plan, proposal, reasons); +} +function validateOrder(plan, proposal, reasons) { + const requested = pendingOrder(proposal); + const pending = plan.goals.filter((item) => item.status === "pending" && item.steeringStatus === undefined).map((item) => item.id); + if (requested.length === 0) + reasons.push("reorder_pending requires ids"); + if (new Set(requested).size !== requested.length) + reasons.push("duplicate pending id"); + if (requested.some((id) => !pending.includes(id))) + reasons.push("unknown pending id"); +} +function validateCriterion(plan, proposal, reasons) { + const target = goal(plan, targets(proposal)[0]); + const criterionId = text(proposal, "criterionId"); + if (target === undefined) + reasons.push("revise_criterion requires goalId"); + else if (criterionId === undefined || target.successCriteria.every((item) => item.id !== criterionId)) + reasons.push("revise_criterion requires criterionId"); + const model = read(proposal, "userModel"); + if (read(proposal, "scenario") === undefined && read(proposal, "expectedEvidence") === undefined && model === undefined) + reasons.push("revise_criterion requires update"); + if (model !== undefined && !isModel(model)) + reasons.push("invalid userModel"); +} +function nextId(plan, offset) { + const max = plan.goals.reduce((current, item) => { + const digits = /^G(\d+)(?:-|$)/u.exec(item.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + offset).padStart(3, "0")}`; +} +function makeGoal(plan, childGoal, evidence, now, offset) { + const id = nextId(plan, offset); + const digits = /^G(\d+)/u.exec(id)?.[1]; + const goalIndex = digits === undefined ? plan.goals.length + offset - 1 : Number(digits) - 1; + return { id, title: childGoal.title, objective: childGoal.objective, status: "pending", successCriteria: seedDefaultSuccessCriteria(goalIndex, childGoal.objective), attempt: 0, createdAt: now, updatedAt: now, evidence }; +} +export function applySteeringMutation(plan, proposal, audit) { + const next = structuredClone(plan); + if (!audit.invariant.accepted) + return next; + const now = proposal.now?.toISOString() ?? iso(); + if (proposal.kind === "add_subgoal") + next.goals.push(makeGoal(next, { title: proposal.title ?? "", objective: proposal.objective ?? "" }, proposal.evidence, now, 1)); + if (proposal.kind === "reorder_pending") { + const order = pendingOrder(proposal); + next.goals = [...order.map((id) => goal(next, id)).filter((item) => item !== undefined), ...next.goals.filter((item) => !order.includes(item.id))]; + } + if (proposal.kind === "revise_pending_wording") + reviseWording(next, proposal, now); + if (proposal.kind === "split_subgoal" || proposal.kind === "mark_blocked_superseded") + splitOrBlock(next, proposal, now); + if (proposal.kind === "revise_criterion") + reviseCriterion(next, proposal, now); + if (proposal.kind !== "annotate_ledger") + next.updatedAt = now; + return next; +} +function reviseWording(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) + return; + target.title = revised(proposal, "revisedTitle", "title") ?? target.title; + target.objective = revised(proposal, "revisedObjective", "objective") ?? target.objective; + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; +} +function splitOrBlock(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) + return; + const replacements = children(proposal).map((item, index) => makeGoal(plan, item, proposal.evidence, now, index + 1)); + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; + if (replacements.length === 0) { + target.status = "blocked"; + target.steeringStatus = "blocked"; + target.blockedReason = proposal.blockedReason ?? proposal.rationale; + } + else { + target.steeringStatus = "superseded"; + target.supersededBy = replacements.map((item) => item.id); + for (const item of replacements) + item.supersedes = [target.id]; + plan.goals.splice(plan.goals.indexOf(target) + 1, 0, ...replacements); + } + if (plan.activeGoalId === target.id) + delete plan.activeGoalId; +} +function reviseCriterion(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + const index = target?.successCriteria.findIndex((item) => item.id === proposal.criterionId) ?? -1; + const current = target?.successCriteria[index]; + if (target === undefined || current === undefined) + return; + const model = read(proposal, "userModel"); + target.successCriteria[index] = { ...current, scenario: text(proposal, "scenario") ?? current.scenario, expectedEvidence: text(proposal, "expectedEvidence") ?? current.expectedEvidence, userModel: isModel(model) ? model : current.userModel }; + target.updatedAt = now; +} +function isProposal(value) { + return isPlain(value) && isKind(read(value, "kind")) && isSource(read(value, "source")) && isText(read(value, "evidence")) && isText(read(value, "rationale")); +} +export function parseUlwLoopSteeringDirective(text) { + const match = /(?:^|\s)(?:OMO_ULW_LOOP_STEER|omo\.ulw-loop\.steer|omo ulw-loop steer):\s*([\s\S]+)$/u.exec(text); + if (match?.[1] === undefined) + return null; + try { + const parsed = JSON.parse(match[1].trim()); + return isProposal(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + throw error; + } +} +export async function steerUlwLoop(repoRoot, proposal, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const key = proposal.idempotencyKey ?? proposal.promptSignature; + const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key)); + if (prior?.steering !== undefined) + return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true }; + const audit = validateUlwLoopSteeringProposal(plan, proposal); + const accepted = audit.invariant.accepted; + const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan; + const finalAudit = { ...audit, before: plan }; + if (accepted) + finalAudit.after = next; + if (accepted) + await writePlan(repoRoot, next, scope); + await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope); + return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false }; + }); +} +function ledgerEntry(proposal, audit, at) { + const entry = { at, kind: audit.invariant.accepted ? (proposal.kind === "revise_criterion" ? "criteria_revised" : "steering_accepted") : "steering_rejected", evidence: proposal.evidence, message: proposal.rationale, steering: audit, mutationKind: proposal.kind }; + const goalId = audit.targetGoalIds[0]; + if (goalId !== undefined) + entry.goalId = goalId; + if (proposal.criterionId !== undefined) + entry.criterionId = proposal.criterionId; + if (proposal.idempotencyKey !== undefined) + entry.idempotencyKey = proposal.idempotencyKey; + if (audit.before !== undefined) + entry.before = audit.before; + if (audit.after !== undefined) + entry.after = audit.after; + return entry; +} diff --git a/plugins/omo/components/ulw-loop/dist/types.d.ts b/plugins/omo/components/ulw-loop/dist/types.d.ts new file mode 100644 index 0000000..b639dd0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/types.d.ts @@ -0,0 +1,5 @@ +export * from "./command-types.js"; +export * from "./constants.js"; +export * from "./domain-types.js"; +export * from "./runtime.js"; +export * from "./steering-types.js"; diff --git a/plugins/omo/components/ulw-loop/dist/types.js b/plugins/omo/components/ulw-loop/dist/types.js new file mode 100644 index 0000000..b639dd0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/types.js @@ -0,0 +1,5 @@ +export * from "./command-types.js"; +export * from "./constants.js"; +export * from "./domain-types.js"; +export * from "./runtime.js"; +export * from "./steering-types.js"; diff --git a/plugins/omo/test/aggregate-hooks.test.mjs b/plugins/omo/test/aggregate-hooks.test.mjs index e9ad3f2..6792e54 100644 --- a/plugins/omo/test/aggregate-hooks.test.mjs +++ b/plugins/omo/test/aggregate-hooks.test.mjs @@ -83,6 +83,23 @@ test("#given aggregate hook commands #when inspected #then commands stay Node-ba assert(commands.every((command) => !command.includes("\\"))); }); +test("#given aggregate hook commands #when packaging is verified #then referenced component CLI targets exist", async () => { + // given + const hooks = await readJson("hooks/hooks.json"); + + // when + const componentCliTargets = collectCommandHooks(hooks, "hooks/hooks.json") + .map(({ handler }) => /^node "\$\{PLUGIN_ROOT\}\/(components\/[^"]+\/dist\/cli\.js)"/.exec(handler.command)?.[1]) + .filter((target) => typeof target === "string"); + const missingComponentCliTargets = []; + for (const target of componentCliTargets) { + if (!(await exists(target))) missingComponentCliTargets.push(target); + } + + // then + assert.deepEqual(missingComponentCliTargets, []); +}); + test("#given component hook commands #when inspected #then standalone packages expose Codex status messages", async () => { // given const componentHooks = await readComponentHookManifests(); From db999aae9b7ef073b8d9b6cd18d9429ad8f4d537 Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 06:28:06 +0000 Subject: [PATCH 2/6] fix: keep LSP runtime inside OMO bundle --- .../omo/components/lsp-tools-mcp/CHANGELOG.md | 20 + plugins/omo/components/lsp-tools-mcp/LICENSE | 21 + plugins/omo/components/lsp-tools-mcp/NOTICE | 3 + .../omo/components/lsp-tools-mcp/README.md | 102 ++++ .../components/lsp-tools-mcp/dist/cli.d.ts | 2 + .../omo/components/lsp-tools-mcp/dist/cli.js | 30 ++ .../dist/lsp/cleanup-errors.d.ts | 1 + .../lsp-tools-mcp/dist/lsp/cleanup-errors.js | 6 + .../dist/lsp/client-wrapper.d.ts | 13 + .../lsp-tools-mcp/dist/lsp/client-wrapper.js | 109 +++++ .../lsp-tools-mcp/dist/lsp/client.d.ts | 20 + .../lsp-tools-mcp/dist/lsp/client.js | 129 +++++ .../lsp-tools-mcp/dist/lsp/config-loader.d.ts | 16 + .../lsp-tools-mcp/dist/lsp/config-loader.js | 155 ++++++ .../lsp-tools-mcp/dist/lsp/connection.d.ts | 4 + .../lsp-tools-mcp/dist/lsp/connection.js | 66 +++ .../lsp-tools-mcp/dist/lsp/constants.d.ts | 10 + .../lsp-tools-mcp/dist/lsp/constants.js | 10 + .../dist/lsp/directory-diagnostics.d.ts | 3 + .../dist/lsp/directory-diagnostics.js | 123 +++++ .../lsp-tools-mcp/dist/lsp/errors.d.ts | 35 ++ .../lsp-tools-mcp/dist/lsp/errors.js | 56 +++ .../lsp-tools-mcp/dist/lsp/formatters.d.ts | 12 + .../lsp-tools-mcp/dist/lsp/formatters.js | 106 ++++ .../dist/lsp/infer-extension.d.ts | 1 + .../lsp-tools-mcp/dist/lsp/infer-extension.js | 58 +++ .../dist/lsp/json-rpc-connection.d.ts | 36 ++ .../dist/lsp/json-rpc-connection.js | 247 ++++++++++ .../dist/lsp/language-mappings.d.ts | 4 + .../dist/lsp/language-mappings.js | 169 +++++++ .../lsp-tools-mcp/dist/lsp/manager.d.ts | 46 ++ .../lsp-tools-mcp/dist/lsp/manager.js | 291 +++++++++++ .../dist/lsp/process-signal-cleanup.d.ts | 1 + .../dist/lsp/process-signal-cleanup.js | 17 + .../lsp-tools-mcp/dist/lsp/process.d.ts | 25 + .../lsp-tools-mcp/dist/lsp/process.js | 153 ++++++ .../dist/lsp/server-definitions.d.ts | 4 + .../dist/lsp/server-definitions.js | 158 ++++++ .../dist/lsp/server-installation.d.ts | 2 + .../dist/lsp/server-installation.js | 50 ++ .../dist/lsp/server-resolution.d.ts | 11 + .../dist/lsp/server-resolution.js | 86 ++++ .../lsp-tools-mcp/dist/lsp/transport.d.ts | 25 + .../lsp-tools-mcp/dist/lsp/transport.js | 243 +++++++++ .../lsp-tools-mcp/dist/lsp/types.d.ts | 124 +++++ .../lsp-tools-mcp/dist/lsp/types.js | 1 + .../lsp-tools-mcp/dist/lsp/utils.d.ts | 3 + .../lsp-tools-mcp/dist/lsp/utils.js | 35 ++ .../dist/lsp/workspace-edit.d.ts | 8 + .../lsp-tools-mcp/dist/lsp/workspace-edit.js | 113 +++++ .../lsp-tools-mcp/dist/mcp-lifecycle-log.d.ts | 4 + .../lsp-tools-mcp/dist/mcp-lifecycle-log.js | 32 ++ .../components/lsp-tools-mcp/dist/mcp.d.ts | 36 ++ .../omo/components/lsp-tools-mcp/dist/mcp.js | 173 +++++++ .../components/lsp-tools-mcp/dist/tools.d.ts | 90 ++++ .../components/lsp-tools-mcp/dist/tools.js | 460 ++++++++++++++++++ .../omo/components/lsp-tools-mcp/package.json | 37 ++ plugins/omo/components/lsp/.mcp.json | 2 +- plugins/omo/components/lsp/CHANGELOG.md | 4 + plugins/omo/components/lsp/README.md | 6 +- plugins/omo/components/lsp/package.json | 2 +- .../components/lsp/test/package-smoke.test.ts | 4 +- plugins/omo/package-lock.json | 33 +- plugins/omo/package.json | 1 + plugins/omo/test/aggregate-hooks.test.mjs | 27 +- plugins/omo/test/aggregate-mcp.test.mjs | 12 +- 66 files changed, 3855 insertions(+), 31 deletions(-) create mode 100644 plugins/omo/components/lsp-tools-mcp/CHANGELOG.md create mode 100644 plugins/omo/components/lsp-tools-mcp/LICENSE create mode 100644 plugins/omo/components/lsp-tools-mcp/NOTICE create mode 100644 plugins/omo/components/lsp-tools-mcp/README.md create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/cli.d.ts create mode 100755 plugins/omo/components/lsp-tools-mcp/dist/cli.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/client.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/client.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/process.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/process.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/types.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/types.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/mcp.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/mcp.js create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/tools.d.ts create mode 100644 plugins/omo/components/lsp-tools-mcp/dist/tools.js create mode 100644 plugins/omo/components/lsp-tools-mcp/package.json diff --git a/plugins/omo/components/lsp-tools-mcp/CHANGELOG.md b/plugins/omo/components/lsp-tools-mcp/CHANGELOG.md new file mode 100644 index 0000000..4bff36a --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [0.1.0] - 2026-05-18 + +### Added + +- Initial standalone extraction from `codex-lsp`: + - LSP runtime (`src/lsp/*`) + - MCP server (`src/mcp.ts`) + - Tool definitions (`src/tools.ts`) + - Standalone CLI (`src/cli.ts`, `mcp` subcommand only) +- Config path override support: + - `LSP_TOOLS_MCP_PROJECT_CONFIG` + - `LSP_TOOLS_MCP_USER_CONFIG` +- Full test suite import (excluding Codex-specific hook tests) +- CI workflow matrix (ubuntu/macos/windows x node 20/22) +- Release-triggered npm publish workflow +- Repository governance files (ruleset, CODEOWNERS, dependabot, issue templates, PR template) diff --git a/plugins/omo/components/lsp-tools-mcp/LICENSE b/plugins/omo/components/lsp-tools-mcp/LICENSE new file mode 100644 index 0000000..09aac3c --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/omo/components/lsp-tools-mcp/NOTICE b/plugins/omo/components/lsp-tools-mcp/NOTICE new file mode 100644 index 0000000..a3adc17 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/NOTICE @@ -0,0 +1,3 @@ +lsp-tools-mcp extracts the standalone LSP runtime from codex-lsp into a reusable package. + +The package includes adapted code originally developed for pi-lsp-client. diff --git a/plugins/omo/components/lsp-tools-mcp/README.md b/plugins/omo/components/lsp-tools-mcp/README.md new file mode 100644 index 0000000..e01071f --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/README.md @@ -0,0 +1,102 @@ +# lsp-tools-mcp + +[![ci](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Standalone Language Server Protocol tools exposed as a stdio MCP server. + +## Used By + +This package is the upstream source of truth for two downstream plugins. Codex consumes the repository-level package directly; OpenCode consumes the same runtime as a built-in MCP package. + +| Project | Path | Role | +|---------|------|------| +| **[codex-lsp](https://github.com/code-yeongyu/codex-lsp)** | `packages/lsp-tools-mcp/` | Codex plugin that reuses these LSP MCP tools plus a Codex-specific PostToolUse diagnostics hook. | +| **[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)** (a.k.a. `oh-my-opencode`) | `vendor/lsp-tools-mcp/` | OpenCode plugin that registers this server as a built-in Tier-1 stdio MCP. Exposes `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, and `lsp_status` to all agents. | + +If you fix or extend the LSP runtime here, downstream adapters should reuse this package. Do not fork the runtime into a downstream; land changes here instead. + +## Quick Start + +```bash +npm install +npm run check +npm test +npm run build +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp +``` + +## MCP Tools + +This server exposes the following tools: + +- `lsp.status` +- `lsp.diagnostics` +- `lsp.goto_definition` +- `lsp.find_references` +- `lsp.symbols` +- `lsp.prepare_rename` +- `lsp.rename` + +Tool aliases are also available for compatibility: + +- `lsp_status` +- `lsp_diagnostics` +- `lsp_goto_definition` +- `lsp_find_references` +- `lsp_symbols` +- `lsp_prepare_rename` +- `lsp_rename` + +When an MCP host registers this server under the name `lsp` (the default in both downstreams), the tools are exposed to agents as `lsp_status`, `lsp_diagnostics`, and so on, matching the alias names above. + +## Configuration + +Default config paths (matches codex-lsp's historical layout): + +- Project: `.codex/lsp-client.json` +- User: `~/.codex/lsp-client.json` + +Path overrides via environment variables: + +- `LSP_TOOLS_MCP_PROJECT_CONFIG` +- `LSP_TOOLS_MCP_USER_CONFIG` + +Examples (oh-my-openagent points the project config at `.opencode/lsp.json` via the env var): + +```bash +LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json node dist/cli.js mcp +LSP_TOOLS_MCP_USER_CONFIG=.opencode/lsp.json node dist/cli.js mcp +``` + +Example config file: + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +## Architecture + +- `src/lsp/*` standalone LSP runtime (process management, JSON-RPC transport, configuration, diagnostics, workspace edits) +- `src/tools.ts` MCP tool definitions and handlers +- `src/mcp.ts` stdio MCP server entry and registration +- `src/cli.ts` standalone CLI entry (`mcp` subcommand only) + +## Local Development + +```bash +npm install +npm run check +npm test +npm pack --dry-run +``` + +## License + +[MIT](LICENSE) diff --git a/plugins/omo/components/lsp-tools-mcp/dist/cli.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/cli.js b/plugins/omo/components/lsp-tools-mcp/dist/cli.js new file mode 100755 index 0000000..8625954 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/cli.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; +import { disposeDefaultLspManager } from "./lsp/manager.js"; +import { runMcpStdioServer } from "./mcp.js"; +import { writeMcpLifecycleLog } from "./mcp-lifecycle-log.js"; +async function main() { + const [command = "mcp"] = argv.slice(2); + try { + if (command === "mcp") { + await runMcpStdioServer(process.stdin, process.stdout, { + log: writeMcpLifecycleLog, + onIdleTimeout: async () => { + await disposeDefaultLspManager(); + process.exit(0); + }, + }); + return; + } + stderr.write("Usage: lsp-tools-mcp [mcp]\n"); + process.exitCode = 2; + } + finally { + await disposeDefaultLspManager(); + } +} +main().catch(async (error) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + await disposeDefaultLspManager(); + process.exitCode = 1; +}); diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts new file mode 100644 index 0000000..447ce59 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts @@ -0,0 +1 @@ +export declare function reportBestEffortCleanupError(operation: string, error: unknown): void; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.js new file mode 100644 index 0000000..dadca2d --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/cleanup-errors.js @@ -0,0 +1,6 @@ +export function reportBestEffortCleanupError(operation, error) { + if (process.env["CODEX_LSP_DEBUG_CLEANUP"] !== "1") + return; + const message = error instanceof Error ? error.message : String(error); + console.error(`[codex-lsp] ignored ${operation} failure during cleanup: ${message}`); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.d.ts new file mode 100644 index 0000000..eed0154 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.d.ts @@ -0,0 +1,13 @@ +import type { LspClient } from "./client.js"; +import { type LspManager } from "./manager.js"; +import type { ServerLookupResult } from "./types.js"; +export declare function isDirectoryPath(filePath: string): boolean; +export declare function findWorkspaceRoot(filePath: string): string; +export declare function formatServerLookupError(result: Exclude): string; +export interface WithLspClientOptions { + signal?: AbortSignal; + manager?: LspManager; +} +export declare function withLspClient(filePath: string, fn: (client: LspClient) => Promise, toolName: string, options?: WithLspClientOptions): Promise; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.js new file mode 100644 index 0000000..308edb4 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client-wrapper.js @@ -0,0 +1,109 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; +import { isLspDeadConnectionError, LspInvalidPathError, LspRequestTimeoutError, LspServerInitializingError, LspServerLookupError, } from "./errors.js"; +import { getLspManager } from "./manager.js"; +import { findServerForExtension } from "./server-resolution.js"; +const WORKSPACE_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"]; +export function isDirectoryPath(filePath) { + try { + return statSync(filePath).isDirectory(); + } + catch { + return false; + } +} +export function findWorkspaceRoot(filePath) { + const abs = resolve(filePath); + let dir = abs; + if (!isDirectoryPath(dir)) { + dir = dirname(dir); + } + let prevDir = ""; + while (dir !== prevDir) { + for (const marker of WORKSPACE_MARKERS) { + if (existsSync(join(dir, marker))) { + return dir; + } + } + prevDir = dir; + dir = dirname(dir); + } + return dirname(abs); +} +export function formatServerLookupError(result) { + if (result.status === "not_installed") { + const { server, installHint } = result; + return [ + `LSP server '${server.id}' is configured but NOT INSTALLED.`, + "", + `Command not found: ${server.command[0]}`, + "", + "To install:", + ` ${installHint}`, + "", + `Supported extensions: ${server.extensions.join(", ")}`, + "", + "After installation, the server will be available automatically.", + ].join("\n"); + } + return [ + `No LSP server configured for extension: ${result.extension}`, + "", + `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`, + "", + "Configure a custom server in '.codex/lsp-client.json':", + " {", + ' "lsp": {', + ' "my-server": {', + ' "command": ["my-lsp", "--stdio"],', + ` "extensions": ["${result.extension}"]`, + " }", + " }", + " }", + ].join("\n"); +} +const READ_ONLY_RETRY_TOOLS = new Set([ + "diagnostics", + "definition", + "references", + "documentSymbols", + "workspaceSymbols", + "prepareRename", +]); +export async function withLspClient(filePath, fn, toolName, options = {}) { + const absPath = resolve(filePath); + if (isDirectoryPath(absPath)) { + throw new LspInvalidPathError("Directory paths are not supported by this LSP tool. " + + "Use lsp.diagnostics with a directory path for directory diagnostics."); + } + const ext = extname(absPath); + const result = findServerForExtension(ext); + if (result.status !== "found") { + throw new LspServerLookupError(formatServerLookupError(result)); + } + const server = result.server; + const root = findWorkspaceRoot(absPath); + const manager = options.manager ?? getLspManager(); + const acquireAndCall = async (allowRetry) => { + const client = await manager.getClient(root, server, options.signal); + try { + return await fn(client); + } + catch (err) { + if (allowRetry && READ_ONLY_RETRY_TOOLS.has(toolName) && isLspDeadConnectionError(err)) { + manager.invalidateClient(root, server.id, client); + return acquireAndCall(false); + } + if (err instanceof LspRequestTimeoutError) { + if (manager.isServerInitializing(root, server.id)) { + throw new LspServerInitializingError(err); + } + } + throw err; + } + finally { + manager.releaseClient(root, server.id); + } + }; + return acquireAndCall(true); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.d.ts new file mode 100644 index 0000000..3d57f66 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.d.ts @@ -0,0 +1,20 @@ +import { LspClientConnection } from "./connection.js"; +import type { Diagnostic, DocumentSymbol, Location, LocationLink, PrepareRenameDefaultBehavior, PrepareRenameResult, Range, SymbolInfo, WorkspaceEdit } from "./types.js"; +export declare class LspClient extends LspClientConnection { + private readonly openedFiles; + private readonly documentVersions; + private readonly lastSyncedText; + private readonly diagnosticPullErrors; + getDiagnosticPullErrors(): readonly Error[]; + openFile(filePath: string): Promise; + definition(filePath: string, line: number, character: number): Promise | null>; + references(filePath: string, line: number, character: number, includeDeclaration?: boolean): Promise; + documentSymbols(filePath: string): Promise>; + workspaceSymbols(query: string): Promise; + private isUnsupportedDiagnosticPullError; + diagnostics(filePath: string): Promise<{ + items: Diagnostic[]; + }>; + prepareRename(filePath: string, line: number, character: number): Promise; + rename(filePath: string, line: number, character: number, newName: string): Promise; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.js new file mode 100644 index 0000000..d5688ac --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/client.js @@ -0,0 +1,129 @@ +import { readFileSync } from "node:fs"; +import { extname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { LspClientConnection } from "./connection.js"; +import { getLanguageId } from "./language-mappings.js"; +const POST_OPEN_DELAY_MS = 1000; +const POST_DIAGNOSTICS_WAIT_MS = 500; +export class LspClient extends LspClientConnection { + constructor() { + super(...arguments); + this.openedFiles = new Set(); + this.documentVersions = new Map(); + this.lastSyncedText = new Map(); + this.diagnosticPullErrors = []; + } + getDiagnosticPullErrors() { + return this.diagnosticPullErrors; + } + async openFile(filePath) { + const absPath = resolve(filePath); + const uri = pathToFileURL(absPath).href; + const text = readFileSync(absPath, "utf-8"); + if (!this.openedFiles.has(absPath)) { + const ext = extname(absPath); + const languageId = getLanguageId(ext); + const version = 1; + await this.sendNotification("textDocument/didOpen", { + textDocument: { + uri, + languageId, + version, + text, + }, + }); + this.openedFiles.add(absPath); + this.documentVersions.set(uri, version); + this.lastSyncedText.set(uri, text); + await new Promise((r) => setTimeout(r, POST_OPEN_DELAY_MS)); + return; + } + const prevText = this.lastSyncedText.get(uri); + if (prevText === text) { + return; + } + const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1; + this.documentVersions.set(uri, nextVersion); + this.lastSyncedText.set(uri, text); + await this.sendNotification("textDocument/didChange", { + textDocument: { uri, version: nextVersion }, + contentChanges: [{ text }], + }); + await this.sendNotification("textDocument/didSave", { + textDocument: { uri }, + text, + }); + } + async definition(filePath, line, character) { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }); + } + async references(filePath, line, character, includeDeclaration = true) { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + context: { includeDeclaration }, + }); + } + async documentSymbols(filePath) { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/documentSymbol", { + textDocument: { uri: pathToFileURL(absPath).href }, + }); + } + async workspaceSymbols(query) { + return this.sendRequest("workspace/symbol", { query }); + } + isUnsupportedDiagnosticPullError(error) { + if (!(error instanceof Error)) + return false; + const code = "code" in error && typeof error.code === "number" ? error.code : undefined; + if (code === -32601) + return true; + return /unsupported|not supported|method not found|unknown request/i.test(error.message); + } + async diagnostics(filePath) { + const absPath = resolve(filePath); + const uri = pathToFileURL(absPath).href; + await this.openFile(absPath); + await new Promise((r) => setTimeout(r, POST_DIAGNOSTICS_WAIT_MS)); + try { + const result = await this.sendRequest("textDocument/diagnostic", { + textDocument: { uri }, + }); + if (result.items) { + return { items: result.items }; + } + } + catch (error) { + if (!this.isUnsupportedDiagnosticPullError(error)) { + this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + return { items: this.getStoredDiagnostics(uri) }; + } + async prepareRename(filePath, line, character) { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/prepareRename", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }); + } + async rename(filePath, line, character, newName) { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/rename", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + newName, + }); + } +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.d.ts new file mode 100644 index 0000000..e4597a0 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.d.ts @@ -0,0 +1,16 @@ +import type { ResolvedServer } from "./types.js"; +interface ConfigJson { + lsp?: Record; +} +type ConfigSource = "project" | "user"; +export interface ServerWithSource extends ResolvedServer { + source: "project" | "user" | "builtin"; +} +export declare function getConfigPaths(): { + project: string; + user: string; +}; +export declare function loadAllConfigs(): Map; +export declare function getMergedServers(): ServerWithSource[]; +export declare function getDisabledServerIds(): Set; +export {}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.js new file mode 100644 index 0000000..3112e3b --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/config-loader.js @@ -0,0 +1,155 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { BUILTIN_SERVERS } from "./server-definitions.js"; +export function getConfigPaths() { + const cwd = process.cwd(); + const projectOverride = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + const userOverride = process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + return { + project: projectOverride + ? isAbsolute(projectOverride) + ? projectOverride + : join(cwd, projectOverride) + : join(cwd, ".codex", "lsp-client.json"), + user: userOverride + ? isAbsolute(userOverride) + ? userOverride + : join(homedir(), userOverride) + : join(homedir(), ".codex", "lsp-client.json"), + }; +} +function loadJsonFile(path) { + if (!existsSync(path)) + return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")); + return isConfigJson(parsed) ? parsed : null; + } + catch { + return null; + } +} +export function loadAllConfigs() { + const paths = getConfigPaths(); + const configs = new Map(); + const project = loadJsonFile(paths.project); + if (project) + configs.set("project", project); + const user = loadJsonFile(paths.user); + if (user) + configs.set("user", user); + return configs; +} +export function getMergedServers() { + const configs = loadAllConfigs(); + const servers = []; + const disabled = new Set(); + const seen = new Set(); + const sources = ["project", "user"]; + for (const source of sources) { + const config = configs.get(source); + if (!config?.lsp) + continue; + for (const [id, rawEntry] of Object.entries(config.lsp)) { + const entry = parseLspEntry(rawEntry); + if (!entry) + continue; + if (entry.disabled) { + disabled.add(id); + continue; + } + if (seen.has(id)) + continue; + if (!entry.command || !entry.extensions) + continue; + const server = { + id, + command: entry.command, + extensions: entry.extensions, + priority: entry.priority ?? 0, + source, + }; + if (entry.env !== undefined) { + server.env = entry.env; + } + if (entry.initialization !== undefined) { + server.initialization = entry.initialization; + } + servers.push(server); + seen.add(id); + } + } + for (const [id, config] of Object.entries(BUILTIN_SERVERS)) { + if (disabled.has(id) || seen.has(id)) + continue; + servers.push({ + id, + command: config.command, + extensions: config.extensions, + priority: -100, + source: "builtin", + }); + } + return servers.sort((a, b) => { + if (a.source !== b.source) { + const order = { + project: 0, + user: 1, + builtin: 2, + }; + return order[a.source] - order[b.source]; + } + return b.priority - a.priority; + }); +} +function isConfigJson(value) { + if (!isRecord(value)) + return false; + const lsp = value["lsp"]; + return lsp === undefined || isRecord(lsp); +} +function parseLspEntry(value) { + return isLspEntry(value) ? value : null; +} +function isLspEntry(value) { + if (!isRecord(value)) + return false; + const disabled = value["disabled"]; + const command = value["command"]; + const extensions = value["extensions"]; + const priority = value["priority"]; + const env = value["env"]; + const initialization = value["initialization"]; + return ((disabled === undefined || typeof disabled === "boolean") && + (command === undefined || isStringArray(command)) && + (extensions === undefined || isStringArray(extensions)) && + (priority === undefined || typeof priority === "number") && + (env === undefined || isStringRecord(env)) && + (initialization === undefined || isRecord(initialization))); +} +function isStringArray(value) { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} +function isStringRecord(value) { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +export function getDisabledServerIds() { + const configs = loadAllConfigs(); + const disabled = new Set(); + for (const config of configs.values()) { + if (!config.lsp) + continue; + for (const [id, rawEntry] of Object.entries(config.lsp)) { + const entry = parseLspEntry(rawEntry); + if (!entry) + continue; + if (entry.disabled) + disabled.add(id); + } + } + return disabled; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.d.ts new file mode 100644 index 0000000..7359019 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.d.ts @@ -0,0 +1,4 @@ +import { LspClientTransport } from "./transport.js"; +export declare class LspClientConnection extends LspClientTransport { + initialize(): Promise; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.js new file mode 100644 index 0000000..dbce95b --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/connection.js @@ -0,0 +1,66 @@ +import { pathToFileURL } from "node:url"; +import { LspClientTransport } from "./transport.js"; +const INITIALIZE_SETTLE_MS = 300; +export class LspClientConnection extends LspClientTransport { + async initialize() { + const rootUri = pathToFileURL(this.root).href; + await this.sendRequest("initialize", { + processId: process.pid, + rootUri, + rootPath: this.root, + workspaceFolders: [{ uri: rootUri, name: "workspace" }], + capabilities: { + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + definition: { linkSupport: true }, + references: {}, + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + publishDiagnostics: {}, + rename: { + prepareSupport: true, + prepareSupportDefaultBehavior: 1, + honorsChangeAnnotations: true, + }, + codeAction: { + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + "source.fixAll", + ], + }, + }, + isPreferredSupport: true, + disabledSupport: true, + dataSupport: true, + resolveSupport: { + properties: ["edit", "command"], + }, + }, + }, + workspace: { + symbol: {}, + workspaceFolders: true, + configuration: true, + applyEdit: true, + workspaceEdit: { + documentChanges: true, + }, + }, + }, + initializationOptions: this.server.initialization, + }); + await this.sendNotification("initialized"); + await this.sendNotification("workspace/didChangeConfiguration", { + settings: { json: { validate: { enable: true } } }, + }); + // Some servers accept initialized before their diagnostics/indexing handlers are ready. + await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS)); + } +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.d.ts new file mode 100644 index 0000000..be50ea0 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.d.ts @@ -0,0 +1,10 @@ +export declare const DEFAULT_MAX_REFERENCES = 200; +export declare const DEFAULT_MAX_SYMBOLS = 200; +export declare const DEFAULT_MAX_DIAGNOSTICS = 200; +export declare const DEFAULT_MAX_DIRECTORY_FILES = 50; +export declare const REQUEST_TIMEOUT_MS = 15000; +export declare const INIT_TIMEOUT_MS = 60000; +export declare const IDLE_TIMEOUT_MS: number; +export declare const REAPER_INTERVAL_MS = 60000; +export declare const STOP_HARD_KILL_TIMEOUT_MS = 5000; +export declare const STOP_SIGKILL_GRACE_MS = 1000; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.js new file mode 100644 index 0000000..d8548d6 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/constants.js @@ -0,0 +1,10 @@ +export const DEFAULT_MAX_REFERENCES = 200; +export const DEFAULT_MAX_SYMBOLS = 200; +export const DEFAULT_MAX_DIAGNOSTICS = 200; +export const DEFAULT_MAX_DIRECTORY_FILES = 50; +export const REQUEST_TIMEOUT_MS = 15_000; +export const INIT_TIMEOUT_MS = 60_000; +export const IDLE_TIMEOUT_MS = 5 * 60_000; +export const REAPER_INTERVAL_MS = 60_000; +export const STOP_HARD_KILL_TIMEOUT_MS = 5_000; +export const STOP_SIGKILL_GRACE_MS = 1_000; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.d.ts new file mode 100644 index 0000000..ff735d4 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.d.ts @@ -0,0 +1,3 @@ +import type { SeverityFilter } from "./types.js"; +export declare function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[]; +export declare function aggregateDiagnosticsForDirectory(directory: string, extension: string, severity?: SeverityFilter, maxFiles?: number): Promise; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.js new file mode 100644 index 0000000..c797aba --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/directory-diagnostics.js @@ -0,0 +1,123 @@ +import { existsSync, lstatSync, readdirSync } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { findWorkspaceRoot, formatServerLookupError } from "./client-wrapper.js"; +import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants.js"; +import { LspInvalidPathError, LspServerLookupError } from "./errors.js"; +import { filterDiagnosticsBySeverity, formatDiagnostic } from "./formatters.js"; +import { getLspManager } from "./manager.js"; +import { findServerForExtension } from "./server-resolution.js"; +const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]); +export function collectFilesWithExtension(dir, extension, maxFiles) { + const files = []; + function walk(currentDir) { + if (files.length >= maxFiles) + return; + let entries = []; + try { + entries = readdirSync(currentDir); + } + catch { + return; + } + for (const entry of entries) { + if (files.length >= maxFiles) + return; + const fullPath = join(currentDir, entry); + let stat; + try { + stat = lstatSync(fullPath); + } + catch { + continue; + } + if (!stat || stat.isSymbolicLink()) + continue; + if (stat.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry)) { + walk(fullPath); + } + } + else if (stat.isFile() && extname(fullPath) === extension) { + files.push(fullPath); + } + } + } + walk(dir); + return files; +} +export async function aggregateDiagnosticsForDirectory(directory, extension, severity, maxFiles = DEFAULT_MAX_DIRECTORY_FILES) { + if (!extension.startsWith(".")) { + throw new LspInvalidPathError(`Extension must start with a dot (e.g., ".ts", not "${extension}"). Use ".${extension}" instead.`); + } + const absDir = resolve(directory); + if (!existsSync(absDir)) { + throw new LspInvalidPathError(`Directory does not exist: ${absDir}`); + } + const serverResult = findServerForExtension(extension); + if (serverResult.status !== "found") { + throw new LspServerLookupError(formatServerLookupError(serverResult)); + } + const server = serverResult.server; + const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1); + const wasCapped = allFiles.length > maxFiles; + const filesToProcess = allFiles.slice(0, maxFiles); + if (filesToProcess.length === 0) { + return [ + `Directory: ${absDir}`, + `Extension: ${extension}`, + "Files scanned: 0", + `No files found with extension "${extension}".`, + ].join("\n"); + } + const root = findWorkspaceRoot(absDir); + const manager = getLspManager(); + const allDiagnostics = []; + const fileErrors = []; + const client = await manager.getClient(root, server); + try { + for (const file of filesToProcess) { + try { + const result = await client.diagnostics(file); + const filtered = filterDiagnosticsBySeverity(result.items, severity); + allDiagnostics.push(...filtered.map((diagnostic) => ({ + filePath: file, + diagnostic, + }))); + } + catch (e) { + fileErrors.push({ + file, + error: e instanceof Error ? e.message : String(e), + }); + } + } + } + finally { + manager.releaseClient(root, server.id); + } + const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS); + const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS; + const lines = [ + `Directory: ${absDir}`, + `Extension: ${extension}`, + `Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`, + `Files with errors: ${fileErrors.length}`, + `Total diagnostics: ${allDiagnostics.length}`, + ]; + if (fileErrors.length > 0) { + lines.push("", "File processing errors:"); + for (const { file, error } of fileErrors) { + lines.push(` ${file}: ${error}`); + } + } + if (displayDiagnostics.length > 0) { + lines.push(""); + for (const { filePath, diagnostic } of displayDiagnostics) { + lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`); + } + if (wasDiagCapped) { + lines.push("", `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`); + } + } + return lines.join("\n"); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.d.ts new file mode 100644 index 0000000..b7fb944 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.d.ts @@ -0,0 +1,35 @@ +export declare class LspConnectionClosedError extends Error { + readonly serverId: string; + readonly root: string; + readonly name = "LspConnectionClosedError"; + constructor(serverId: string, root: string, message?: string); +} +export declare class LspProcessExitedError extends Error { + readonly serverId: string; + readonly root: string; + readonly exitCode: number | null; + readonly stderrTail?: string | undefined; + readonly name = "LspProcessExitedError"; + constructor(serverId: string, root: string, exitCode: number | null, stderrTail?: string | undefined); +} +export declare class LspRequestTimeoutError extends Error { + readonly method: string; + readonly stderrTail?: string | undefined; + readonly name = "LspRequestTimeoutError"; + constructor(method: string, stderrTail?: string | undefined); +} +export declare class LspInvalidPathError extends Error { + readonly name = "LspInvalidPathError"; +} +export declare class LspServerLookupError extends Error { + readonly name = "LspServerLookupError"; +} +export declare class LspServerInitializingError extends Error { + readonly originalError: LspRequestTimeoutError; + readonly name = "LspServerInitializingError"; + constructor(originalError: LspRequestTimeoutError); +} +export declare class LspProcessSpawnError extends Error { + readonly name = "LspProcessSpawnError"; +} +export declare function isLspDeadConnectionError(err: unknown): err is LspConnectionClosedError | LspProcessExitedError; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.js new file mode 100644 index 0000000..391da34 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/errors.js @@ -0,0 +1,56 @@ +export class LspConnectionClosedError extends Error { + constructor(serverId, root, message) { + super(message ?? `LSP connection closed for ${serverId} at ${root}`); + this.serverId = serverId; + this.root = root; + this.name = "LspConnectionClosedError"; + } +} +export class LspProcessExitedError extends Error { + constructor(serverId, root, exitCode, stderrTail) { + const stderrSuffix = stderrTail ? `\nstderr tail: ${stderrTail}` : ""; + super(`LSP server ${serverId} at ${root} exited with code ${exitCode ?? "null"}${stderrSuffix}`); + this.serverId = serverId; + this.root = root; + this.exitCode = exitCode; + this.stderrTail = stderrTail; + this.name = "LspProcessExitedError"; + } +} +export class LspRequestTimeoutError extends Error { + constructor(method, stderrTail) { + const stderrSuffix = stderrTail ? `\nrecent stderr: ${stderrTail}` : ""; + super(`LSP request timeout (method: ${method})${stderrSuffix}`); + this.method = method; + this.stderrTail = stderrTail; + this.name = "LspRequestTimeoutError"; + } +} +export class LspInvalidPathError extends Error { + constructor() { + super(...arguments); + this.name = "LspInvalidPathError"; + } +} +export class LspServerLookupError extends Error { + constructor() { + super(...arguments); + this.name = "LspServerLookupError"; + } +} +export class LspServerInitializingError extends Error { + constructor(originalError) { + super(`LSP server is still initializing. Please retry in a few seconds. Original error: ${originalError.message}`); + this.originalError = originalError; + this.name = "LspServerInitializingError"; + } +} +export class LspProcessSpawnError extends Error { + constructor() { + super(...arguments); + this.name = "LspProcessSpawnError"; + } +} +export function isLspDeadConnectionError(err) { + return err instanceof LspConnectionClosedError || err instanceof LspProcessExitedError; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.d.ts new file mode 100644 index 0000000..f4755fa --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.d.ts @@ -0,0 +1,12 @@ +import type { Diagnostic, DocumentSymbol, Location, LocationLink, PrepareRenameDefaultBehavior, PrepareRenameResult, Range, SeverityFilter, SymbolInfo } from "./types.js"; +import type { ApplyResult } from "./workspace-edit.js"; +export declare function uriToPath(uri: string): string; +export declare function formatLocation(loc: Location | LocationLink): string; +export declare function formatSymbolKind(kind: number): string; +export declare function formatSeverity(severity: number | undefined): string; +export declare function formatDocumentSymbol(symbol: DocumentSymbol, indent?: number): string; +export declare function formatSymbolInfo(symbol: SymbolInfo): string; +export declare function formatDiagnostic(diag: Diagnostic): string; +export declare function filterDiagnosticsBySeverity(diagnostics: Diagnostic[], severityFilter?: SeverityFilter): Diagnostic[]; +export declare function formatPrepareRenameResult(result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null): string; +export declare function formatApplyResult(result: ApplyResult): string; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.js new file mode 100644 index 0000000..20a17be --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/formatters.js @@ -0,0 +1,106 @@ +import { fileURLToPath } from "node:url"; +import { SEVERITY_MAP, SYMBOL_KIND_MAP } from "./language-mappings.js"; +const DIAGNOSTIC_SEVERITY_FILTERS = { + error: 1, + warning: 2, + information: 3, + hint: 4, +}; +export function uriToPath(uri) { + return fileURLToPath(uri); +} +export function formatLocation(loc) { + if ("targetUri" in loc) { + const uri = uriToPath(loc.targetUri); + const line = loc.targetRange.start.line + 1; + const char = loc.targetRange.start.character; + return `${uri}:${line}:${char}`; + } + const uri = uriToPath(loc.uri); + const line = loc.range.start.line + 1; + const char = loc.range.start.character; + return `${uri}:${line}:${char}`; +} +export function formatSymbolKind(kind) { + return SYMBOL_KIND_MAP[kind] ?? `Unknown(${kind})`; +} +export function formatSeverity(severity) { + if (!severity) + return "unknown"; + return SEVERITY_MAP[severity] ?? `unknown(${severity})`; +} +export function formatDocumentSymbol(symbol, indent = 0) { + const prefix = " ".repeat(indent); + const kind = formatSymbolKind(symbol.kind); + const line = symbol.range.start.line + 1; + let result = `${prefix}${symbol.name} (${kind}) - line ${line}`; + if (symbol.children && symbol.children.length > 0) { + for (const child of symbol.children) { + result += `\n${formatDocumentSymbol(child, indent + 1)}`; + } + } + return result; +} +export function formatSymbolInfo(symbol) { + const kind = formatSymbolKind(symbol.kind); + const loc = formatLocation(symbol.location); + const container = symbol.containerName ? ` (in ${symbol.containerName})` : ""; + return `${symbol.name} (${kind})${container} - ${loc}`; +} +export function formatDiagnostic(diag) { + const severity = formatSeverity(diag.severity); + const line = diag.range.start.line + 1; + const char = diag.range.start.character; + const source = diag.source ? `[${diag.source}]` : ""; + const code = diag.code ? ` (${diag.code})` : ""; + return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`; +} +export function filterDiagnosticsBySeverity(diagnostics, severityFilter) { + if (!severityFilter || severityFilter === "all") { + return diagnostics; + } + const targetSeverity = DIAGNOSTIC_SEVERITY_FILTERS[severityFilter]; + return diagnostics.filter((d) => d.severity === targetSeverity); +} +export function formatPrepareRenameResult(result) { + if (!result) + return "Cannot rename at this position"; + if ("defaultBehavior" in result) { + return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"; + } + if ("range" in result && result.range) { + const startLine = result.range.start.line + 1; + const startChar = result.range.start.character; + const endLine = result.range.end.line + 1; + const endChar = result.range.end.character; + const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""; + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`; + } + if ("start" in result && "end" in result) { + const startLine = result.start.line + 1; + const startChar = result.start.character; + const endLine = result.end.line + 1; + const endChar = result.end.character; + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`; + } + return "Cannot rename at this position"; +} +export function formatApplyResult(result) { + const lines = []; + if (result.success) { + lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`); + for (const file of result.filesModified) { + lines.push(` - ${file}`); + } + } + else { + lines.push("Failed to apply some changes:"); + for (const err of result.errors) { + lines.push(` Error: ${err}`); + } + if (result.filesModified.length > 0) { + lines.push(`Successfully modified: ${result.filesModified.join(", ")}`); + } + } + return lines.join("\n"); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.d.ts new file mode 100644 index 0000000..e3b1f9a --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.d.ts @@ -0,0 +1 @@ +export declare function inferExtensionFromDirectory(directory: string): string | null; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.js new file mode 100644 index 0000000..9f27b40 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/infer-extension.js @@ -0,0 +1,58 @@ +import { lstatSync, readdirSync } from "node:fs"; +import { extname, join } from "node:path"; +import { EXT_TO_LANG } from "./language-mappings.js"; +const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]); +const MAX_SCAN_ENTRIES = 500; +export function inferExtensionFromDirectory(directory) { + const extensionCounts = new Map(); + let scanned = 0; + function walk(dir) { + if (scanned >= MAX_SCAN_ENTRIES) + return; + let entries; + try { + entries = readdirSync(dir); + } + catch { + return; + } + for (const entry of entries) { + if (scanned >= MAX_SCAN_ENTRIES) + return; + const fullPath = join(dir, entry); + let stat; + try { + stat = lstatSync(fullPath); + } + catch { + continue; + } + if (stat.isSymbolicLink()) + continue; + scanned++; + if (stat.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry)) { + walk(fullPath); + } + } + else if (stat.isFile()) { + const ext = extname(fullPath); + if (ext && ext in EXT_TO_LANG) { + extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1); + } + } + } + } + walk(directory); + if (extensionCounts.size === 0) + return null; + let maxExt = ""; + let maxCount = 0; + for (const [ext, count] of extensionCounts) { + if (count > maxCount) { + maxCount = count; + maxExt = ext; + } + } + return maxExt || null; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.d.ts new file mode 100644 index 0000000..06b51d3 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.d.ts @@ -0,0 +1,36 @@ +type NotificationHandler = (params: unknown) => void; +type RequestHandler = (params: unknown) => Promise | unknown; +export declare class JsonRpcConnection { + private readonly reader; + private readonly writer; + private readonly pendingRequests; + private readonly notificationHandlers; + private readonly requestHandlers; + private readonly closeHandlers; + private readonly errorHandlers; + private inputBuffer; + private nextRequestId; + private listening; + private disposed; + constructor(reader: NodeJS.ReadableStream, writer: NodeJS.WritableStream); + listen(): void; + onNotification(method: string, handler: NotificationHandler): void; + onRequest(method: string, handler: RequestHandler): void; + onClose(handler: () => void): void; + onError(handler: (error: Error) => void): void; + sendRequest(method: string, params?: unknown): Promise; + sendNotification(method: string, params?: unknown): Promise; + dispose(): void; + private readonly handleData; + private readonly handleClose; + private readonly handleStreamError; + private drainInputBuffer; + private dispatchBody; + private handleResponse; + private handleNotification; + private handleRequest; + private writeError; + private writeMessage; + private emitError; +} +export {}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.js new file mode 100644 index 0000000..c182e26 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/json-rpc-connection.js @@ -0,0 +1,247 @@ +const HEADER_SEPARATOR = "\r\n\r\n"; +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INTERNAL_ERROR = -32603; +export class JsonRpcConnection { + constructor(reader, writer) { + this.reader = reader; + this.writer = writer; + this.pendingRequests = new Map(); + this.notificationHandlers = new Map(); + this.requestHandlers = new Map(); + this.closeHandlers = []; + this.errorHandlers = []; + this.inputBuffer = Buffer.alloc(0); + this.nextRequestId = 1; + this.listening = false; + this.disposed = false; + this.handleData = (chunk) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + this.inputBuffer = Buffer.concat([this.inputBuffer, chunkBuffer]); + this.drainInputBuffer(); + }; + this.handleClose = () => { + for (const handler of this.closeHandlers) { + handler(); + } + }; + this.handleStreamError = (error) => { + this.emitError(error); + }; + } + listen() { + if (this.listening) + return; + this.listening = true; + this.reader.on("data", this.handleData); + this.reader.on("close", this.handleClose); + this.reader.on("end", this.handleClose); + this.reader.on("error", this.handleStreamError); + this.writer.on("error", this.handleStreamError); + } + onNotification(method, handler) { + this.notificationHandlers.set(method, handler); + } + onRequest(method, handler) { + this.requestHandlers.set(method, handler); + } + onClose(handler) { + this.closeHandlers.push(handler); + } + onError(handler) { + this.errorHandlers.push(handler); + } + async sendRequest(method, params) { + if (this.disposed) + throw new Error("JSON-RPC connection is disposed"); + const id = this.nextRequestId; + this.nextRequestId += 1; + const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }; + const responsePromise = new Promise((resolve, reject) => { + this.pendingRequests.set(String(id), { + resolve(result) { + resolve(result); + }, + reject, + }); + }); + try { + await this.writeMessage(message); + } + catch (error) { + this.pendingRequests.delete(String(id)); + throw error; + } + return responsePromise; + } + async sendNotification(method, params) { + if (this.disposed) + return; + const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params }; + await this.writeMessage(message); + } + dispose() { + if (this.disposed) + return; + this.disposed = true; + this.reader.off("data", this.handleData); + this.reader.off("close", this.handleClose); + this.reader.off("end", this.handleClose); + this.reader.off("error", this.handleStreamError); + this.writer.off("error", this.handleStreamError); + for (const pending of this.pendingRequests.values()) { + pending.reject(new Error("JSON-RPC connection disposed")); + } + this.pendingRequests.clear(); + this.notificationHandlers.clear(); + this.requestHandlers.clear(); + } + drainInputBuffer() { + while (true) { + const headerEnd = this.inputBuffer.indexOf(HEADER_SEPARATOR); + if (headerEnd === -1) + return; + const headers = this.inputBuffer.subarray(0, headerEnd).toString("ascii"); + const contentLength = parseContentLength(headers); + if (contentLength === null) { + this.inputBuffer = Buffer.alloc(0); + this.emitError(new Error("JSON-RPC message is missing Content-Length header")); + return; + } + const bodyStart = headerEnd + Buffer.byteLength(HEADER_SEPARATOR); + const bodyEnd = bodyStart + contentLength; + if (this.inputBuffer.length < bodyEnd) + return; + const body = this.inputBuffer.subarray(bodyStart, bodyEnd).toString("utf8"); + this.inputBuffer = this.inputBuffer.subarray(bodyEnd); + this.dispatchBody(body); + } + } + dispatchBody(body) { + let parsed; + try { + parsed = JSON.parse(body); + } + catch (error) { + void this.writeError(null, PARSE_ERROR, error instanceof Error ? error.message : "Parse error").catch((writeError) => this.emitError(toError(writeError))); + return; + } + if (!isJsonRpcObject(parsed)) { + void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC message").catch((error) => this.emitError(toError(error))); + return; + } + if ("id" in parsed && ("result" in parsed || "error" in parsed)) { + this.handleResponse(parsed); + return; + } + if (typeof parsed["method"] !== "string") { + const id = getMessageId(parsed) ?? null; + void this.writeError(id, INVALID_REQUEST, "Invalid JSON-RPC method").catch((error) => this.emitError(toError(error))); + return; + } + if ("id" in parsed) { + this.handleRequest(parsed); + return; + } + this.handleNotification(parsed["method"], parsed["params"]); + } + handleResponse(message) { + const id = getMessageId(message); + if (id === undefined) + return; + const pending = this.pendingRequests.get(String(id)); + if (!pending) + return; + this.pendingRequests.delete(String(id)); + if ("error" in message) { + pending.reject(jsonRpcErrorToError(message["error"])); + return; + } + pending.resolve(message["result"]); + } + handleNotification(method, params) { + const handler = this.notificationHandlers.get(method); + if (!handler) + return; + try { + handler(params); + } + catch (error) { + this.emitError(toError(error)); + } + } + handleRequest(message) { + const id = getMessageId(message); + if (id === undefined) { + void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC id").catch((error) => this.emitError(toError(error))); + return; + } + const method = typeof message["method"] === "string" ? message["method"] : ""; + const handler = this.requestHandlers.get(method); + if (!handler) { + void this.writeError(id, METHOD_NOT_FOUND, `Method not found: ${method}`).catch((error) => this.emitError(toError(error))); + return; + } + Promise.resolve() + .then(() => handler(message["params"])) + .then((result) => this.writeMessage({ jsonrpc: "2.0", id, result }), (error) => this.writeError(id, INTERNAL_ERROR, toError(error).message)) + .catch((error) => this.emitError(toError(error))); + } + async writeError(id, code, message) { + await this.writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); + } + writeMessage(message) { + const body = JSON.stringify(message); + const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; + return new Promise((resolve, reject) => { + this.writer.write(payload, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + emitError(error) { + for (const handler of this.errorHandlers) { + handler(error); + } + } +} +function parseContentLength(headers) { + for (const line of headers.split("\r\n")) { + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) + continue; + const name = line.slice(0, separatorIndex).trim().toLowerCase(); + if (name !== "content-length") + continue; + const value = Number.parseInt(line.slice(separatorIndex + 1).trim(), 10); + return Number.isFinite(value) && value >= 0 ? value : null; + } + return null; +} +function isJsonRpcObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function getMessageId(message) { + const id = message["id"]; + if (typeof id === "number" || typeof id === "string" || id === null) + return id; + return undefined; +} +function jsonRpcErrorToError(value) { + if (!isJsonRpcObject(value)) + return new Error("JSON-RPC request failed"); + const message = typeof value["message"] === "string" ? value["message"] : "JSON-RPC request failed"; + const error = new Error(message); + if (typeof value["code"] === "number") { + error.name = `JsonRpcError(${value["code"]})`; + } + return error; +} +function toError(error) { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.d.ts new file mode 100644 index 0000000..27efa7a --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.d.ts @@ -0,0 +1,4 @@ +export declare const SYMBOL_KIND_MAP: Record; +export declare const SEVERITY_MAP: Record; +export declare const EXT_TO_LANG: Record; +export declare function getLanguageId(ext: string): string; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.js new file mode 100644 index 0000000..fa572be --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/language-mappings.js @@ -0,0 +1,169 @@ +export const SYMBOL_KIND_MAP = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", +}; +export const SEVERITY_MAP = { + 1: "error", + 2: "warning", + 3: "information", + 4: "hint", +}; +export const EXT_TO_LANG = { + ".abap": "abap", + ".bat": "bat", + ".bib": "bibtex", + ".bibtex": "bibtex", + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + ".coffee": "coffeescript", + ".c": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".c++": "cpp", + ".cs": "csharp", + ".css": "css", + ".d": "d", + ".pas": "pascal", + ".pascal": "pascal", + ".diff": "diff", + ".patch": "diff", + ".dart": "dart", + ".dockerfile": "dockerfile", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".fs": "fsharp", + ".fsi": "fsharp", + ".fsx": "fsharp", + ".fsscript": "fsharp", + ".gitcommit": "git-commit", + ".gitrebase": "git-rebase", + ".go": "go", + ".groovy": "groovy", + ".gleam": "gleam", + ".hbs": "handlebars", + ".handlebars": "handlebars", + ".hs": "haskell", + ".html": "html", + ".htm": "html", + ".ini": "ini", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".tex": "latex", + ".latex": "latex", + ".less": "less", + ".lua": "lua", + ".makefile": "makefile", + makefile: "makefile", + ".md": "markdown", + ".markdown": "markdown", + ".m": "objective-c", + ".mm": "objective-cpp", + ".pl": "perl", + ".pm": "perl", + ".pm6": "perl6", + ".php": "php", + ".ps1": "powershell", + ".psm1": "powershell", + ".pug": "jade", + ".jade": "jade", + ".py": "python", + ".pyi": "python", + ".r": "r", + ".cshtml": "razor", + ".razor": "razor", + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + ".ru": "ruby", + ".erb": "erb", + ".html.erb": "erb", + ".js.erb": "erb", + ".css.erb": "erb", + ".json.erb": "erb", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".scala": "scala", + ".shader": "shaderlab", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".ksh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".mtsx": "typescriptreact", + ".ctsx": "typescriptreact", + ".xml": "xml", + ".xsl": "xsl", + ".yaml": "yaml", + ".yml": "yaml", + ".mjs": "javascript", + ".cjs": "javascript", + ".vue": "vue", + ".zig": "zig", + ".zon": "zig", + ".astro": "astro", + ".ml": "ocaml", + ".mli": "ocaml", + ".tf": "terraform", + ".tfvars": "terraform-vars", + ".hcl": "hcl", + ".nix": "nix", + ".typ": "typst", + ".typc": "typst", + ".ets": "typescript", + ".lhs": "haskell", + ".kt": "kotlin", + ".kts": "kotlin", + ".prisma": "prisma", + ".h": "c", + ".hpp": "cpp", + ".hh": "cpp", + ".hxx": "cpp", + ".h++": "cpp", + ".objc": "objective-c", + ".objcpp": "objective-cpp", + ".fish": "fish", + ".graphql": "graphql", + ".gql": "graphql", +}; +export function getLanguageId(ext) { + return EXT_TO_LANG[ext] ?? "plaintext"; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.d.ts new file mode 100644 index 0000000..d591ef0 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.d.ts @@ -0,0 +1,46 @@ +import { LspClient } from "./client.js"; +import type { ResolvedServer } from "./types.js"; +export interface ClientSnapshot { + root: string; + serverId: string; + refCount: number; + pendingWaiters: number; + lastUsedAt: number; + isInitializing: boolean; + alive: boolean; + command: string[]; +} +export interface LspManagerOptions { + idleTimeoutMs?: number; + initTimeoutMs?: number; + reaperIntervalMs?: number; + clientFactory?: (root: string, server: ResolvedServer) => LspClient; + now?: () => number; +} +export declare class LspManager { + private readonly clients; + private reaperHandle; + private signalDisposer; + private disposed; + private readonly idleTimeoutMs; + private readonly initTimeoutMs; + private readonly reaperIntervalMs; + private readonly clientFactory; + private readonly now; + constructor(options?: LspManagerOptions); + private startReaper; + private getKey; + private reapStale; + private tryDeleteIfOrphaned; + getClient(root: string, server: ResolvedServer, signal?: AbortSignal): Promise; + releaseClient(root: string, serverId: string): void; + invalidateClient(root: string, serverId: string, client?: LspClient): void; + warmupClient(root: string, server: ResolvedServer): void; + isServerInitializing(root: string, serverId: string): boolean; + getSnapshot(): ClientSnapshot[]; + hasClient(root: string, serverId: string): boolean; + clientCount(): number; + stopAll(): Promise; +} +export declare function getLspManager(): LspManager; +export declare function disposeDefaultLspManager(): Promise; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.js new file mode 100644 index 0000000..16c0e71 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/manager.js @@ -0,0 +1,291 @@ +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { LspClient } from "./client.js"; +import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js"; +import { installProcessSignalCleanup } from "./process-signal-cleanup.js"; +async function stopClientBestEffort(client) { + try { + await client.stop(); + } + catch (error) { + reportBestEffortCleanupError("client stop", error); + } +} +function awaitWithSignal(promise, signal) { + if (!signal) + return promise; + return new Promise((resolve, reject) => { + let settled = false; + const onAbort = () => { + if (settled) + return; + settled = true; + reject(new DOMException("Aborted", "AbortError")); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + promise.then((value) => { + if (settled) + return; + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, (err) => { + if (settled) + return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(err); + }); + }); +} +export class LspManager { + constructor(options = {}) { + this.clients = new Map(); + this.reaperHandle = null; + this.signalDisposer = null; + this.disposed = false; + this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS; + this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS; + this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS; + this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server)); + this.now = options.now ?? (() => Date.now()); + this.startReaper(); + this.signalDisposer = installProcessSignalCleanup(() => this.stopAll()); + } + startReaper() { + if (this.reaperHandle) + return; + this.reaperHandle = setInterval(() => { + this.reapStale(); + }, this.reaperIntervalMs); + if (typeof this.reaperHandle.unref === "function") { + this.reaperHandle.unref(); + } + } + getKey(root, serverId) { + return `${root}::${serverId}`; + } + reapStale() { + const t = this.now(); + for (const [key, managed] of this.clients) { + if (managed.isInitializing && + managed.initializingSince !== null && + t - managed.initializingSince > this.initTimeoutMs) { + void stopClientBestEffort(managed.client); + this.clients.delete(key); + continue; + } + if (!managed.isInitializing && + managed.refCount === 0 && + managed.pendingWaiters === 0 && + t - managed.lastUsedAt > this.idleTimeoutMs) { + void stopClientBestEffort(managed.client); + this.clients.delete(key); + } + } + } + async tryDeleteIfOrphaned(key, managed) { + if (managed.refCount === 0 && + managed.pendingWaiters === 0 && + !managed.isInitializing && + this.clients.get(key) === managed) { + this.clients.delete(key); + await stopClientBestEffort(managed.client); + } + } + async getClient(root, server, signal) { + if (this.disposed) { + throw new Error("LspManager has been disposed"); + } + signal?.throwIfAborted(); + const key = this.getKey(root, server.id); + let managed = this.clients.get(key); + if (managed) { + const t = this.now(); + if (managed.isInitializing && + managed.initializingSince !== null && + t - managed.initializingSince > this.initTimeoutMs) { + await stopClientBestEffort(managed.client); + this.clients.delete(key); + managed = undefined; + } + } + if (managed) { + if (managed.initPromise) { + managed.pendingWaiters++; + try { + await awaitWithSignal(managed.initPromise, signal); + } + catch (err) { + managed.pendingWaiters--; + await this.tryDeleteIfOrphaned(key, managed); + throw err; + } + managed.pendingWaiters--; + } + if (signal?.aborted) { + await this.tryDeleteIfOrphaned(key, managed); + signal.throwIfAborted(); + } + if (!managed.client.isAlive()) { + await stopClientBestEffort(managed.client); + this.clients.delete(key); + return this.getClient(root, server, signal); + } + managed.refCount++; + managed.lastUsedAt = this.now(); + return managed.client; + } + const client = this.clientFactory(root, server); + const initStartedAt = this.now(); + const initPromise = (async () => { + await client.start(); + await client.initialize(); + })(); + const newManaged = { + client, + refCount: 0, + pendingWaiters: 1, + lastUsedAt: initStartedAt, + initPromise, + isInitializing: true, + initializingSince: initStartedAt, + }; + this.clients.set(key, newManaged); + try { + await awaitWithSignal(initPromise, signal); + } + catch (err) { + newManaged.pendingWaiters--; + if (this.clients.get(key) === newManaged) { + this.clients.delete(key); + } + await stopClientBestEffort(client); + throw err; + } + newManaged.pendingWaiters--; + newManaged.isInitializing = false; + newManaged.initializingSince = null; + newManaged.initPromise = null; + if (signal?.aborted) { + await this.tryDeleteIfOrphaned(key, newManaged); + signal.throwIfAborted(); + } + newManaged.refCount++; + newManaged.lastUsedAt = this.now(); + return client; + } + releaseClient(root, serverId) { + const key = this.getKey(root, serverId); + const managed = this.clients.get(key); + if (managed && managed.refCount > 0) { + managed.refCount--; + managed.lastUsedAt = this.now(); + } + } + invalidateClient(root, serverId, client) { + const key = this.getKey(root, serverId); + const managed = this.clients.get(key); + if (!managed) + return; + if (client && managed.client !== client) + return; + this.clients.delete(key); + void stopClientBestEffort(managed.client); + } + warmupClient(root, server) { + if (this.disposed) + return; + const key = this.getKey(root, server.id); + if (this.clients.has(key)) + return; + const client = this.clientFactory(root, server); + const initStartedAt = this.now(); + const initPromise = (async () => { + await client.start(); + await client.initialize(); + })(); + const managed = { + client, + refCount: 0, + pendingWaiters: 0, + lastUsedAt: initStartedAt, + initPromise, + isInitializing: true, + initializingSince: initStartedAt, + }; + this.clients.set(key, managed); + initPromise.then(() => { + managed.isInitializing = false; + managed.initializingSince = null; + managed.initPromise = null; + managed.lastUsedAt = this.now(); + }, () => { + if (this.clients.get(key) === managed) { + this.clients.delete(key); + } + void stopClientBestEffort(client); + }); + } + isServerInitializing(root, serverId) { + const managed = this.clients.get(this.getKey(root, serverId)); + return managed?.isInitializing ?? false; + } + getSnapshot() { + const snapshots = []; + for (const [key, managed] of this.clients) { + const [root, serverId] = key.split("::"); + snapshots.push({ + root, + serverId, + refCount: managed.refCount, + pendingWaiters: managed.pendingWaiters, + lastUsedAt: managed.lastUsedAt, + isInitializing: managed.isInitializing, + alive: managed.client.isAlive(), + command: managed.client.command(), + }); + } + return snapshots; + } + hasClient(root, serverId) { + return this.clients.has(this.getKey(root, serverId)); + } + clientCount() { + return this.clients.size; + } + async stopAll() { + this.disposed = true; + if (this.reaperHandle) { + clearInterval(this.reaperHandle); + this.reaperHandle = null; + } + if (this.signalDisposer) { + this.signalDisposer(); + this.signalDisposer = null; + } + const stopPromises = []; + for (const managed of this.clients.values()) { + stopPromises.push(stopClientBestEffort(managed.client)); + } + this.clients.clear(); + await Promise.allSettled(stopPromises); + } +} +let _defaultInstance = null; +export function getLspManager() { + if (!_defaultInstance) { + _defaultInstance = new LspManager(); + } + return _defaultInstance; +} +export async function disposeDefaultLspManager() { + if (_defaultInstance) { + const m = _defaultInstance; + _defaultInstance = null; + await m.stopAll(); + } +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts new file mode 100644 index 0000000..c3a3f85 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts @@ -0,0 +1 @@ +export declare function installProcessSignalCleanup(cleanup: () => Promise): () => void; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.js new file mode 100644 index 0000000..ffc1a12 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process-signal-cleanup.js @@ -0,0 +1,17 @@ +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +export function installProcessSignalCleanup(cleanup) { + const signals = process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGBREAK"] : ["SIGINT", "SIGTERM"]; + const handler = () => { + void cleanup().catch((error) => { + reportBestEffortCleanupError("signal cleanup", error); + }); + }; + for (const signal of signals) { + process.on(signal, handler); + } + return () => { + for (const signal of signals) { + process.removeListener(signal, handler); + } + }; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.d.ts new file mode 100644 index 0000000..f2fd5cb --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.d.ts @@ -0,0 +1,25 @@ +export interface SpawnedProcess { + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr: NodeJS.ReadableStream; + pid: number | undefined; + exitCode: number | null; + exited: Promise; + kill(signal?: NodeJS.Signals): void; + killed: boolean; +} +export interface SpawnOptions { + cwd: string; + env: Record; +} +export interface PreparedSpawnCommand { + command: string; + args: string[]; + shell: false; +} +export declare function validateCwd(cwd: string): { + valid: boolean; + error?: string; +}; +export declare function createSpawnCommand(command: string[], platform?: NodeJS.Platform, commandProcessor?: string, env?: Record): PreparedSpawnCommand; +export declare function spawnProcess(command: string[], options: SpawnOptions): SpawnedProcess; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.js new file mode 100644 index 0000000..fb6eabf --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/process.js @@ -0,0 +1,153 @@ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { LspInvalidPathError, LspProcessSpawnError } from "./errors.js"; +function isMissingProcessError(error) { + if (!(error instanceof Error) || !("code" in error)) + return false; + return error.code === "ESRCH"; +} +function reportKillError(context, error) { + if (!isMissingProcessError(error)) { + reportBestEffortCleanupError(context, error); + } +} +export function validateCwd(cwd) { + try { + if (!existsSync(cwd)) { + return { valid: false, error: `Working directory does not exist: ${cwd}` }; + } + const stats = statSync(cwd); + if (!stats.isDirectory()) { + return { valid: false, error: `Path is not a directory: ${cwd}` }; + } + return { valid: true }; + } + catch (err) { + return { + valid: false, + error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})`, + }; + } +} +function wrap(proc) { + const exitedPromise = new Promise((resolve) => { + proc.once("close", (code) => resolve(code ?? 0)); + proc.once("error", () => resolve(1)); + }); + if (!proc.stdin || !proc.stdout || !proc.stderr) { + throw new LspProcessSpawnError("Spawned process is missing one of stdin/stdout/stderr pipes"); + } + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + get pid() { + return proc.pid ?? undefined; + }, + get exitCode() { + return proc.exitCode; + }, + get killed() { + return proc.killed; + }, + exited: exitedPromise, + kill(signal) { + killProcessTree(proc, signal ?? "SIGTERM"); + }, + }; +} +function killProcessTree(proc, signal) { + if (process.platform === "win32" && proc.pid) { + const result = spawnSync("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" }); + if (!result.error && result.status === 0) + return; + if (result.error) + reportKillError("windows process tree kill", result.error); + } + if (process.platform !== "win32" && proc.pid) { + try { + process.kill(-proc.pid, signal); + return; + } + catch (error) { + reportKillError("process group kill", error); + } + } + try { + proc.kill(signal); + } + catch (error) { + reportKillError("process kill", error); + } +} +function isWindowsShellShim(command) { + const lowerCommand = command.toLowerCase(); + return lowerCommand.endsWith(".cmd") || lowerCommand.endsWith(".bat"); +} +function splitPath(pathValue, platform) { + const separator = platform === "win32" ? ";" : delimiter; + return pathValue.split(separator).filter(Boolean); +} +function getWindowsPathExtensions(env) { + const rawExtensions = env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD"; + const extensions = rawExtensions + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean) + .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`)); + return [...new Set(["", ...extensions, ".exe", ".cmd", ".bat"])]; +} +function resolveWindowsCommand(command, env) { + const hasPathSeparator = command.includes("/") || command.includes("\\"); + const pathValue = env["PATH"] ?? env["Path"] ?? ""; + const baseDirectories = hasPathSeparator ? [""] : splitPath(pathValue, "win32"); + const extensions = getWindowsPathExtensions(env); + for (const baseDirectory of baseDirectories) { + for (const extension of extensions) { + const candidate = baseDirectory ? join(baseDirectory, `${command}${extension}`) : `${command}${extension}`; + if (existsSync(candidate)) + return candidate; + } + } + return command; +} +export function createSpawnCommand(command, platform = process.platform, commandProcessor = process.env["ComSpec"] ?? "cmd.exe", env = process.env) { + const [cmd, ...args] = command; + if (!cmd) { + throw new LspProcessSpawnError("[lsp] empty command"); + } + if (platform !== "win32") { + return { command: cmd, args, shell: false }; + } + const resolvedCommand = resolveWindowsCommand(cmd, env); + if (!isWindowsShellShim(resolvedCommand)) { + return { command: resolvedCommand, args, shell: false }; + } + return { + command: commandProcessor, + args: ["/d", "/s", "/c", resolvedCommand, ...args], + shell: false, + }; +} +export function spawnProcess(command, options) { + const cwdValidation = validateCwd(options.cwd); + if (!cwdValidation.valid) { + throw new LspInvalidPathError(`[lsp] ${cwdValidation.error}`); + } + const [cmd] = command; + if (!cmd) { + throw new LspProcessSpawnError("[lsp] empty command"); + } + const preparedCommand = createSpawnCommand(command, process.platform, process.env["ComSpec"] ?? "cmd.exe", options.env); + const proc = spawn(preparedCommand.command, preparedCommand.args, { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + shell: preparedCommand.shell, + detached: process.platform !== "win32", + }); + return wrap(proc); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.d.ts new file mode 100644 index 0000000..beb7d5f --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.d.ts @@ -0,0 +1,4 @@ +import type { LspServerConfig } from "./types.js"; +export declare const LSP_INSTALL_HINTS: Record; +export declare const BUILTIN_SERVERS: Record>; +export declare const AUTO_INSTALLABLE_SERVERS: Record; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.js new file mode 100644 index 0000000..39a09cb --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-definitions.js @@ -0,0 +1,158 @@ +export const LSP_INSTALL_HINTS = { + typescript: "npm install -g typescript-language-server typescript", + deno: "Install Deno from https://deno.land", + vue: "npm install -g @vue/language-server", + eslint: "npm install -g vscode-langservers-extracted", + oxlint: "npm install -g oxlint", + biome: "npm install -g @biomejs/biome", + gopls: "go install golang.org/x/tools/gopls@latest", + "ruby-lsp": "gem install ruby-lsp", + basedpyright: "pip install basedpyright", + pyright: "pip install pyright", + ty: "pip install ty", + ruff: "pip install ruff", + "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls", + zls: "See https://github.com/zigtools/zls", + csharp: "dotnet tool install -g csharp-ls", + fsharp: "dotnet tool install -g fsautocomplete", + "sourcekit-lsp": "Included with Xcode or Swift toolchain", + rust: "Install rust-analyzer and ensure it is in PATH. If using rustup: rustup component add rust-analyzer. " + + "If rust-analyzer exits while loading rust-src: rustup component remove rust-src && rustup component add rust-src.", + clangd: "See https://clangd.llvm.org/installation", + svelte: "npm install -g svelte-language-server", + astro: "npm install -g @astrojs/language-server", + "bash-ls": "npm install -g bash-language-server", + jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls", + "yaml-ls": "npm install -g yaml-language-server", + "lua-ls": "See https://github.com/LuaLS/lua-language-server", + php: "npm install -g intelephense", + dart: "Included with Dart SDK", + "terraform-ls": "See https://github.com/hashicorp/terraform-ls", + terraform: "See https://github.com/hashicorp/terraform-ls", + prisma: "npm install -g prisma", + "ocaml-lsp": "opam install ocaml-lsp-server", + texlab: "See https://github.com/latex-lsp/texlab", + dockerfile: "npm install -g dockerfile-language-server-nodejs", + gleam: "See https://gleam.run/getting-started/installing/", + "clojure-lsp": "See https://clojure-lsp.io/installation/", + nixd: "nix profile install nixpkgs#nixd", + tinymist: "See https://github.com/Myriad-Dreamin/tinymist", + "haskell-language-server": "ghcup install hls", + bash: "npm install -g bash-language-server", + "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp", +}; +export const BUILTIN_SERVERS = { + typescript: { + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + }, + deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] }, + vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] }, + eslint: { + command: ["vscode-eslint-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], + }, + oxlint: { + command: ["oxlint", "--lsp"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], + }, + biome: { + command: ["biome", "lsp-proxy", "--stdio"], + extensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ".json", + ".jsonc", + ".vue", + ".astro", + ".svelte", + ".css", + ".graphql", + ".gql", + ".html", + ], + }, + gopls: { command: ["gopls"], extensions: [".go"] }, + "ruby-lsp": { + command: ["rubocop", "--lsp"], + extensions: [".rb", ".rake", ".gemspec", ".ru"], + }, + basedpyright: { + command: ["basedpyright-langserver", "--stdio"], + extensions: [".py", ".pyi"], + }, + pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, + ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] }, + ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] }, + "elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] }, + zls: { command: ["zls"], extensions: [".zig", ".zon"] }, + csharp: { command: ["csharp-ls"], extensions: [".cs"] }, + fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] }, + "sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] }, + rust: { command: ["rust-analyzer"], extensions: [".rs"] }, + clangd: { + command: ["clangd", "--background-index", "--clang-tidy"], + extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], + }, + svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] }, + astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] }, + bash: { + command: ["bash-language-server", "start"], + extensions: [".sh", ".bash", ".zsh", ".ksh"], + }, + "bash-ls": { + command: ["bash-language-server", "start"], + extensions: [".sh", ".bash", ".zsh", ".ksh"], + }, + jdtls: { command: ["jdtls"], extensions: [".java"] }, + "yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] }, + "lua-ls": { command: ["lua-language-server"], extensions: [".lua"] }, + php: { command: ["intelephense", "--stdio"], extensions: [".php"] }, + dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] }, + terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + "terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] }, + "ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] }, + texlab: { command: ["texlab"], extensions: [".tex", ".bib"] }, + dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] }, + gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] }, + "clojure-lsp": { + command: ["clojure-lsp", "listen"], + extensions: [".clj", ".cljs", ".cljc", ".edn"], + }, + nixd: { command: ["nixd"], extensions: [".nix"] }, + tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] }, + "haskell-language-server": { + command: ["haskell-language-server-wrapper", "--lsp"], + extensions: [".hs", ".lhs"], + }, + "kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] }, +}; +export const AUTO_INSTALLABLE_SERVERS = { + typescript: ["npm", "install", "-g", "typescript-language-server", "typescript"], + vue: ["npm", "install", "-g", "@vue/language-server"], + eslint: ["npm", "install", "-g", "vscode-langservers-extracted"], + oxlint: ["npm", "install", "-g", "oxlint"], + biome: ["npm", "install", "-g", "@biomejs/biome"], + svelte: ["npm", "install", "-g", "svelte-language-server"], + astro: ["npm", "install", "-g", "@astrojs/language-server"], + "bash-ls": ["npm", "install", "-g", "bash-language-server"], + bash: ["npm", "install", "-g", "bash-language-server"], + "yaml-ls": ["npm", "install", "-g", "yaml-language-server"], + php: ["npm", "install", "-g", "intelephense"], + prisma: ["npm", "install", "-g", "prisma"], + dockerfile: ["npm", "install", "-g", "dockerfile-language-server-nodejs"], + gopls: ["go", "install", "golang.org/x/tools/gopls@latest"], + pyright: ["pip", "install", "pyright"], + basedpyright: ["pip", "install", "basedpyright"], + ruff: ["pip", "install", "ruff"], + ty: ["pip", "install", "ty"], + "ruby-lsp": ["gem", "install", "ruby-lsp"], + "ocaml-lsp": ["opam", "install", "ocaml-lsp-server"], +}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.d.ts new file mode 100644 index 0000000..7f61ac4 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.d.ts @@ -0,0 +1,2 @@ +export declare function getAdditionalPathBases(workingDirectory: string): string[]; +export declare function isServerInstalled(command: string[]): boolean; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.js new file mode 100644 index 0000000..0a4d28d --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-installation.js @@ -0,0 +1,50 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +export function getAdditionalPathBases(workingDirectory) { + return [join(workingDirectory, "node_modules", ".bin")]; +} +export function isServerInstalled(command) { + if (command.length === 0) + return false; + const [cmd] = command; + if (!cmd) + return false; + if (cmd.includes("/") || cmd.includes("\\")) { + if (existsSync(cmd)) + return true; + } + const isWindows = process.platform === "win32"; + let exts = [""]; + if (isWindows) { + const pathExt = process.env["PATHEXT"] ?? ""; + if (pathExt) { + const systemExts = pathExt.split(";").filter(Boolean); + exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])]; + } + else { + exts = ["", ".exe", ".cmd", ".bat", ".ps1"]; + } + } + let pathEnv = process.env["PATH"] ?? ""; + if (isWindows && !pathEnv) { + pathEnv = process.env["Path"] ?? ""; + } + const paths = pathEnv.split(delimiter); + for (const p of paths) { + for (const suffix of exts) { + if (existsSync(join(p, cmd + suffix))) { + return true; + } + } + } + for (const base of getAdditionalPathBases(process.cwd())) { + for (const suffix of exts) { + if (existsSync(join(base, cmd + suffix))) { + return true; + } + } + } + if (cmd === "node") + return true; + return false; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.d.ts new file mode 100644 index 0000000..f7b81aa --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.d.ts @@ -0,0 +1,11 @@ +import type { ServerLookupResult } from "./types.js"; +export declare function findServerForExtension(ext: string): ServerLookupResult; +export interface ServerStatus { + id: string; + installed: boolean; + extensions: string[]; + disabled: boolean; + source: string; + priority: number; +} +export declare function getAllServers(): ServerStatus[]; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.js new file mode 100644 index 0000000..61c49ae --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/server-resolution.js @@ -0,0 +1,86 @@ +import { getDisabledServerIds, getMergedServers } from "./config-loader.js"; +import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions.js"; +import { isServerInstalled } from "./server-installation.js"; +export function findServerForExtension(ext) { + const servers = getMergedServers(); + for (const server of servers) { + if (server.extensions.includes(ext) && isServerInstalled(server.command)) { + const resolvedServer = { + id: server.id, + command: server.command, + extensions: server.extensions, + priority: server.priority, + }; + if (server.env !== undefined) { + return { + status: "found", + server: { + ...resolvedServer, + env: server.env, + ...(server.initialization === undefined ? {} : { initialization: server.initialization }), + }, + }; + } + return { + status: "found", + server: { + ...resolvedServer, + ...(server.initialization === undefined ? {} : { initialization: server.initialization }), + }, + }; + } + } + for (const server of servers) { + if (server.extensions.includes(ext)) { + const installHint = LSP_INSTALL_HINTS[server.id] ?? `Install '${server.command[0]}' and ensure it's in your PATH`; + return { + status: "not_installed", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + }, + installHint, + }; + } + } + const availableServers = [...new Set(servers.map((s) => s.id))]; + return { + status: "not_configured", + extension: ext, + availableServers, + }; +} +export function getAllServers() { + const servers = getMergedServers(); + const disabled = getDisabledServerIds(); + const result = []; + const seen = new Set(); + for (const server of servers) { + if (seen.has(server.id)) + continue; + result.push({ + id: server.id, + installed: isServerInstalled(server.command), + extensions: server.extensions, + disabled: false, + source: server.source, + priority: server.priority, + }); + seen.add(server.id); + } + for (const id of disabled) { + if (seen.has(id)) + continue; + const builtin = BUILTIN_SERVERS[id]; + result.push({ + id, + installed: builtin ? isServerInstalled(builtin.command) : false, + extensions: builtin?.extensions ?? [], + disabled: true, + source: "disabled", + priority: 0, + }); + } + return result; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.d.ts new file mode 100644 index 0000000..673bff4 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.d.ts @@ -0,0 +1,25 @@ +import { JsonRpcConnection } from "./json-rpc-connection.js"; +import { type SpawnedProcess } from "./process.js"; +import type { Diagnostic, ResolvedServer } from "./types.js"; +export declare class LspClientTransport { + protected readonly root: string; + protected readonly server: ResolvedServer; + protected proc: SpawnedProcess | null; + protected connection: JsonRpcConnection | null; + protected readonly stderrBuffer: string[]; + protected processExited: boolean; + protected readonly diagnosticsStore: Map; + constructor(root: string, server: ResolvedServer); + pid(): number | undefined; + command(): string[]; + start(): Promise; + protected startStderrReading(): void; + private isConnectionClosedError; + protected sendRequest(method: string): Promise; + protected sendRequest(method: string, params: unknown): Promise; + protected sendNotification(method: string): Promise; + protected sendNotification(method: string, params: unknown): Promise; + isAlive(): boolean; + stop(): Promise; + getStoredDiagnostics(uri: string): Diagnostic[]; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.js new file mode 100644 index 0000000..7aa955b --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/transport.js @@ -0,0 +1,243 @@ +import { delimiter } from "node:path"; +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { REQUEST_TIMEOUT_MS, STOP_HARD_KILL_TIMEOUT_MS, STOP_SIGKILL_GRACE_MS } from "./constants.js"; +import { LspConnectionClosedError, LspProcessExitedError, LspRequestTimeoutError } from "./errors.js"; +import { JsonRpcConnection } from "./json-rpc-connection.js"; +import { spawnProcess } from "./process.js"; +import { getAdditionalPathBases } from "./server-installation.js"; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function parseConfigurationItems(params) { + if (!isRecord(params) || !Array.isArray(params["items"])) + return []; + const items = []; + for (const item of params["items"]) { + if (!isRecord(item)) + continue; + const section = item["section"]; + items.push(section === undefined || typeof section !== "string" ? {} : { section }); + } + return items; +} +function parseDiagnosticsParams(params) { + if (!isRecord(params) || typeof params["uri"] !== "string") + return null; + const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : []; + return { uri: params["uri"], diagnostics }; +} +export class LspClientTransport { + constructor(root, server) { + this.root = root; + this.server = server; + this.proc = null; + this.connection = null; + this.stderrBuffer = []; + this.processExited = false; + this.diagnosticsStore = new Map(); + } + pid() { + return this.proc?.pid; + } + command() { + return [...this.server.command]; + } + async start() { + const env = { + ...process.env, + ...this.server.env, + }; + const pathValue = process.platform === "win32" ? (env["PATH"] ?? env["Path"] ?? "") : (env["PATH"] ?? ""); + const spawnPath = [pathValue, ...getAdditionalPathBases(this.root)].filter(Boolean).join(delimiter); + if (process.platform === "win32" && env["Path"] !== undefined) { + env["Path"] = spawnPath; + } + env["PATH"] = spawnPath; + this.proc = spawnProcess(this.server.command, { + cwd: this.root, + env, + }); + this.startStderrReading(); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (this.proc.exitCode !== null) { + const stderr = this.stderrBuffer.join("\n"); + throw new LspProcessExitedError(this.server.id, this.root, this.proc.exitCode, stderr.slice(-2000)); + } + this.connection = new JsonRpcConnection(this.proc.stdout, this.proc.stdin); + this.connection.onNotification("textDocument/publishDiagnostics", (params) => { + const diagnosticsParams = parseDiagnosticsParams(params); + if (diagnosticsParams?.uri) { + this.diagnosticsStore.set(diagnosticsParams.uri, diagnosticsParams.diagnostics); + } + }); + this.connection.onRequest("workspace/configuration", (params) => { + const items = parseConfigurationItems(params); + return items.map((item) => { + if (item.section === "json") + return { validate: { enable: true } }; + return {}; + }); + }); + this.connection.onRequest("client/registerCapability", () => null); + this.connection.onRequest("window/workDoneProgress/create", () => null); + this.connection.onClose(() => { + this.processExited = true; + }); + this.connection.onError((error) => { + reportBestEffortCleanupError("connection error notification", error); + }); + this.connection.listen(); + } + startStderrReading() { + if (!this.proc) + return; + this.proc.stderr.setEncoding("utf-8"); + this.proc.stderr.on("data", (chunk) => { + this.stderrBuffer.push(chunk); + if (this.stderrBuffer.length > 100) { + this.stderrBuffer.shift(); + } + }); + } + isConnectionClosedError(error) { + if (!(error instanceof Error)) { + return false; + } + const code = "code" in error && typeof error.code === "string" ? error.code : undefined; + return (code === "ERR_STREAM_DESTROYED" || + /connection closed|connection is disposed|stream was destroyed/i.test(error.message)); + } + async sendRequest(method, ...args) { + if (!this.connection) + throw new Error("LSP client not started"); + if (this.processExited || (this.proc && this.proc.exitCode !== null)) { + const stderrTail = this.stderrBuffer.slice(-10).join("\n"); + throw new LspProcessExitedError(this.server.id, this.root, this.proc?.exitCode ?? null, stderrTail || undefined); + } + let timeoutHandle = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + const stderrTail = this.stderrBuffer.slice(-5).join("\n"); + reject(new LspRequestTimeoutError(method, stderrTail || undefined)); + }, REQUEST_TIMEOUT_MS); + }); + try { + const requestPromise = args.length === 0 + ? this.connection.sendRequest(method) + : this.connection.sendRequest(method, args[0]); + const result = await Promise.race([requestPromise, timeoutPromise]); + if (timeoutHandle !== null) + clearTimeout(timeoutHandle); + return result; + } + catch (error) { + if (timeoutHandle !== null) + clearTimeout(timeoutHandle); + if (this.processExited || (this.proc && this.proc.exitCode !== null)) { + throw new LspProcessExitedError(this.server.id, this.root, this.proc?.exitCode ?? null, this.stderrBuffer.slice(-10).join("\n") || undefined); + } + if (this.isConnectionClosedError(error)) { + throw new LspConnectionClosedError(this.server.id, this.root, error.message); + } + throw error; + } + } + async sendNotification(method, ...args) { + if (!this.connection) + return; + if (this.processExited || (this.proc && this.proc.exitCode !== null)) + return; + try { + if (args.length === 0) { + await this.connection.sendNotification(method); + } + else { + await this.connection.sendNotification(method, args[0]); + } + } + catch (error) { + if (this.isConnectionClosedError(error)) { + throw new LspConnectionClosedError(this.server.id, this.root, error.message); + } + throw error; + } + } + isAlive() { + return this.proc !== null && !this.processExited && this.proc.exitCode === null; + } + async stop() { + if (this.connection) { + try { + await this.sendRequest("shutdown"); + } + catch (error) { + reportBestEffortCleanupError("shutdown request", error); + } + try { + await this.sendNotification("exit"); + } + catch (error) { + reportBestEffortCleanupError("exit notification", error); + } + try { + this.connection.dispose(); + } + catch (error) { + reportBestEffortCleanupError("connection dispose", error); + } + this.connection = null; + } + const proc = this.proc; + if (proc) { + this.proc = null; + let exitedBeforeTimeout = false; + try { + proc.kill(); + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(resolve, STOP_HARD_KILL_TIMEOUT_MS); + }); + await Promise.race([ + proc.exited + .then(() => { + exitedBeforeTimeout = true; + }) + .finally(() => { + if (timeoutId) + clearTimeout(timeoutId); + }), + timeoutPromise, + ]); + if (!exitedBeforeTimeout) { + try { + proc.kill("SIGKILL"); + await Promise.race([ + proc.exited, + new Promise((resolve) => setTimeout(resolve, STOP_SIGKILL_GRACE_MS)), + ]); + } + catch (error) { + reportBestEffortCleanupError("hard process kill", error); + } + } + } + catch (error) { + reportBestEffortCleanupError("process stop", error); + } + } + this.processExited = true; + this.diagnosticsStore.clear(); + } + getStoredDiagnostics(uri) { + return this.diagnosticsStore.get(uri) ?? []; + } +} +function isDiagnostic(value) { + return isRecord(value) && isRange(value["range"]) && typeof value["message"] === "string"; +} +function isRange(value) { + return isRecord(value) && isPosition(value["start"]) && isPosition(value["end"]); +} +function isPosition(value) { + return isRecord(value) && typeof value["line"] === "number" && typeof value["character"] === "number"; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.d.ts new file mode 100644 index 0000000..e4eee11 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.d.ts @@ -0,0 +1,124 @@ +export interface LspServerConfig { + id: string; + command: string[]; + extensions: string[]; + disabled?: boolean; + env?: Record; + initialization?: Record; +} +export interface ResolvedServer { + id: string; + command: string[]; + extensions: string[]; + priority: number; + env?: Record; + initialization?: Record; +} +export interface ServerLookupInfo { + id: string; + command: string[]; + extensions: string[]; +} +export type ServerLookupResult = { + status: "found"; + server: ResolvedServer; +} | { + status: "not_configured"; + extension: string; + availableServers: string[]; +} | { + status: "not_installed"; + server: ServerLookupInfo; + installHint: string; +}; +export interface Position { + line: number; + character: number; +} +export interface Range { + start: Position; + end: Position; +} +export interface Location { + uri: string; + range: Range; +} +export interface LocationLink { + targetUri: string; + targetRange: Range; + targetSelectionRange: Range; + originSelectionRange?: Range; +} +export interface SymbolInfo { + name: string; + kind: number; + location: Location; + containerName?: string; +} +export interface DocumentSymbol { + name: string; + kind: number; + range: Range; + selectionRange: Range; + children?: DocumentSymbol[]; +} +export interface Diagnostic { + range: Range; + severity?: number; + code?: string | number; + source?: string; + message: string; +} +export interface TextDocumentIdentifier { + uri: string; +} +export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier { + version: number | null; +} +export interface TextEdit { + range: Range; + newText: string; +} +export interface TextDocumentEdit { + textDocument: VersionedTextDocumentIdentifier; + edits: TextEdit[]; +} +export interface CreateFile { + kind: "create"; + uri: string; + options?: { + overwrite?: boolean; + ignoreIfExists?: boolean; + }; +} +export interface RenameFile { + kind: "rename"; + oldUri: string; + newUri: string; + options?: { + overwrite?: boolean; + ignoreIfExists?: boolean; + }; +} +export interface DeleteFile { + kind: "delete"; + uri: string; + options?: { + recursive?: boolean; + ignoreIfNotExists?: boolean; + }; +} +export interface WorkspaceEdit { + changes?: { + [uri: string]: TextEdit[]; + }; + documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[]; +} +export interface PrepareRenameResult { + range: Range; + placeholder?: string; +} +export interface PrepareRenameDefaultBehavior { + defaultBehavior: boolean; +} +export type SeverityFilter = "error" | "warning" | "information" | "hint" | "all"; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.d.ts new file mode 100644 index 0000000..b380103 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.d.ts @@ -0,0 +1,3 @@ +export declare function errorMessage(error: unknown): string; +export declare function formatKnownLspStartupFailure(error: unknown): string | null; +export declare function handleMissingDependencyError(error: unknown): string | null; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.js new file mode 100644 index 0000000..ca9fb69 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/utils.js @@ -0,0 +1,35 @@ +import { LspProcessExitedError } from "./errors.js"; +const RUST_SRC_REPAIR_MESSAGE = [ + "rust-analyzer exited while loading Rust standard library sources.", + "", + "Repair rust-src for the active toolchain:", + " rustup component remove rust-src", + " rustup component add rust-src", +]; +export function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} +export function formatKnownLspStartupFailure(error) { + if (!(error instanceof LspProcessExitedError)) + return null; + if (error.serverId !== "rust") + return null; + const details = error.stderrTail ?? error.message; + const lowerDetails = details.toLowerCase(); + const isRustSrcFailure = lowerDetails.includes("rust-src") && + (lowerDetails.includes("failed to install component") || + lowerDetails.includes("detected conflict") || + lowerDetails.includes("can't load standard library") || + lowerDetails.includes("try installing") || + lowerDetails.includes("sysroot")); + if (!isRustSrcFailure) + return null; + return [...RUST_SRC_REPAIR_MESSAGE, "", "Original stderr tail:", details].join("\n"); +} +export function handleMissingDependencyError(error) { + const knownStartupFailure = formatKnownLspStartupFailure(error); + if (knownStartupFailure) + return knownStartupFailure; + const message = errorMessage(error); + return message.includes("NOT INSTALLED") || message.includes("No LSP server configured") ? message : null; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.d.ts new file mode 100644 index 0000000..b18bf4b --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.d.ts @@ -0,0 +1,8 @@ +import type { WorkspaceEdit } from "./types.js"; +export interface ApplyResult { + success: boolean; + filesModified: string[]; + totalEdits: number; + errors: string[]; +} +export declare function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.js b/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.js new file mode 100644 index 0000000..2535e9d --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/lsp/workspace-edit.js @@ -0,0 +1,113 @@ +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { uriToPath } from "./formatters.js"; +function applyTextEditsToFile(filePath, edits) { + try { + const content = readFileSync(filePath, "utf-8"); + const lines = content.split("\n"); + const sortedEdits = [...edits].sort((a, b) => { + if (b.range.start.line !== a.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + for (const edit of sortedEdits) { + const startLine = edit.range.start.line; + const startChar = edit.range.start.character; + const endLine = edit.range.end.line; + const endChar = edit.range.end.character; + if (startLine === endLine) { + const line = lines[startLine] ?? ""; + lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar); + } + else { + const firstLine = lines[startLine] ?? ""; + const lastLine = lines[endLine] ?? ""; + const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar); + lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n")); + } + } + writeFileSync(filePath, lines.join("\n"), "utf-8"); + return { success: true, editCount: edits.length }; + } + catch (err) { + return { + success: false, + editCount: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} +export function applyWorkspaceEdit(edit) { + if (!edit) { + return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }; + } + const result = { success: true, filesModified: [], totalEdits: 0, errors: [] }; + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + const filePath = uriToPath(uri); + const applyResult = applyTextEditsToFile(filePath, edits); + if (applyResult.success) { + result.filesModified.push(filePath); + result.totalEdits += applyResult.editCount; + } + else { + result.success = false; + result.errors.push(`${filePath}: ${applyResult.error}`); + } + } + } + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if (!("kind" in change)) { + const filePath = uriToPath(change.textDocument.uri); + const applyResult = applyTextEditsToFile(filePath, change.edits); + if (applyResult.success) { + result.filesModified.push(filePath); + result.totalEdits += applyResult.editCount; + } + else { + result.success = false; + result.errors.push(`${filePath}: ${applyResult.error}`); + } + continue; + } + if (change.kind === "create") { + try { + const filePath = uriToPath(change.uri); + writeFileSync(filePath, "", "utf-8"); + result.filesModified.push(filePath); + } + catch (err) { + result.success = false; + result.errors.push(`Create ${change.uri}: ${String(err)}`); + } + } + else if (change.kind === "rename") { + try { + const oldPath = uriToPath(change.oldUri); + const newPath = uriToPath(change.newUri); + const content = readFileSync(oldPath, "utf-8"); + writeFileSync(newPath, content, "utf-8"); + unlinkSync(oldPath); + result.filesModified.push(newPath); + } + catch (err) { + result.success = false; + result.errors.push(`Rename ${change.oldUri}: ${String(err)}`); + } + } + else if (change.kind === "delete") { + try { + const filePath = uriToPath(change.uri); + unlinkSync(filePath); + result.filesModified.push(filePath); + } + catch (err) { + result.success = false; + result.errors.push(`Delete ${change.uri}: ${String(err)}`); + } + } + } + } + return result; +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.d.ts new file mode 100644 index 0000000..0fe80ed --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.d.ts @@ -0,0 +1,4 @@ +type LogFieldValue = boolean | number | string | null; +export declare function mcpLifecycleLogPath(): string; +export declare function writeMcpLifecycleLog(event: string, fields?: Record): void; +export {}; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.js b/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.js new file mode 100644 index 0000000..c7a8703 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/mcp-lifecycle-log.js @@ -0,0 +1,32 @@ +import { appendFileSync, renameSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const LOG_FILE_NAME = "omo-lsp-mcp.log"; +const MAX_LOG_BYTES = 5 * 1024 * 1024; +export function mcpLifecycleLogPath() { + return join(tmpdir(), LOG_FILE_NAME); +} +export function writeMcpLifecycleLog(event, fields = {}) { + const path = mcpLifecycleLogPath(); + try { + rotateLogIfNeeded(path); + appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +function rotateLogIfNeeded(path) { + try { + if (statSync(path).size < MAX_LOG_BYTES) + return; + renameSync(path, `${path}.1`); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/mcp.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/mcp.d.ts new file mode 100644 index 0000000..e31ef90 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/mcp.d.ts @@ -0,0 +1,36 @@ +import { type TextContent } from "./tools.js"; +export type JsonRpcId = string | number | null; +export type McpLifecycleLog = (event: string, fields?: Record) => void; +export interface McpToolDescriptor { + name: string; + title: string; + description: string; + inputSchema: unknown; +} +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} +export interface JsonRpcResult { + capabilities?: Record; + serverInfo?: Record; + protocolVersion?: string; + tools?: McpToolDescriptor[]; + content?: TextContent[]; + isError?: boolean; + [key: string]: unknown; +} +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: JsonRpcResult; + error?: JsonRpcError; +} +export interface McpStdioServerOptions { + readonly idleTimeoutMs?: number; + readonly onIdleTimeout?: () => void | Promise; + readonly log?: McpLifecycleLog; +} +export declare function handleLspMcpRequest(input: unknown): Promise; +export declare function runMcpStdioServer(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, options?: McpStdioServerOptions): Promise; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/mcp.js b/plugins/omo/components/lsp-tools-mcp/dist/mcp.js new file mode 100644 index 0000000..999b463 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/mcp.js @@ -0,0 +1,173 @@ +import { createInterface } from "node:readline"; +import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS } from "./tools.js"; +const SERVER_NAME = "lsp"; +const SERVER_VERSION = "0.1.0"; +const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000; +const noopLog = () => { }; +export async function handleLspMcpRequest(input) { + if (!isRecord(input)) { + return errorResponse(null, -32600, "Invalid Request"); + } + const id = jsonRpcId(input["id"]); + const method = input["method"]; + if (method === "notifications/initialized") + return undefined; + if (method === "ping") + return successResponse(id, {}); + if (method === "initialize") { + const protocolVersion = requestedProtocolVersion(input["params"]); + return successResponse(id, { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + protocolVersion, + }); + } + if (method === "tools/list") { + return successResponse(id, { tools: LSP_MCP_TOOLS.map(describeTool) }); + } + if (method === "resources/list") { + return successResponse(id, { resources: [] }); + } + if (method === "resources/templates/list") { + return successResponse(id, { resourceTemplates: [] }); + } + if (method === "tools/call") { + return handleToolCall(id, input["params"]); + } + return errorResponse(id, -32601, `Method not found: ${String(method)}`); +} +export async function runMcpStdioServer(input = process.stdin, output = process.stdout, options = {}) { + const log = options.log ?? noopLog; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + let idleTimer = null; + let closed = false; + const clearIdleTimer = () => { + if (idleTimer === null) + return; + clearTimeout(idleTimer); + idleTimer = null; + }; + const armIdleTimer = () => { + clearIdleTimer(); + if (idleTimeoutMs <= 0) + return; + idleTimer = setTimeout(() => { + closed = true; + log("idle_timeout", { idle_timeout_ms: idleTimeoutMs }); + void options.onIdleTimeout?.(); + }, idleTimeoutMs); + idleTimer.unref(); + }; + log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs }); + armIdleTimer(); + const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + try { + for await (const line of lines) { + if (closed) + break; + armIdleTimer(); + if (!line.trim()) + continue; + let parsed; + try { + parsed = JSON.parse(line); + } + catch (error) { + log("parse_error", { message: messageFromError(error) }); + output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`); + continue; + } + const id = isRecord(parsed) ? jsonRpcId(parsed["id"]) : null; + const method = isRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null; + log("request", { id: id === null ? null : String(id), method }); + const response = await handleLspMcpRequest(parsed); + if (response) { + output.write(`${JSON.stringify(response)}\n`); + const resultIsError = response.result?.isError === true; + const resultHasActionableError = hasActionableToolError(response.result); + log("response", { + id: String(response.id), + method, + is_error: response.error !== undefined, + result_is_error: resultIsError, + actionable_error: resultHasActionableError, + }); + if (resultIsError || resultHasActionableError) { + const message = actionableToolMessage(response.result); + log("tool_error", { + id: String(response.id), + method, + ...(message === undefined ? {} : { message: message.slice(0, 1_000) }), + }); + } + } + } + } + finally { + clearIdleTimer(); + log("stdio_stopped"); + } +} +function hasActionableToolError(result) { + if (result === undefined) + return false; + if (result.isError === true) + return true; + const details = result["details"]; + return isRecord(details) && typeof details["error"] === "string"; +} +function actionableToolMessage(result) { + const details = result?.["details"]; + if (isRecord(details) && typeof details["error"] === "string") { + return details["error"]; + } + const firstContent = result?.content?.[0]; + return firstContent?.text; +} +async function handleToolCall(id, params) { + if (!isRecord(params) || typeof params["name"] !== "string") { + return errorResponse(id, -32602, "tools/call requires params.name"); + } + try { + const result = await executeLspTool(params["name"], coerceToolArguments(params["arguments"])); + return successResponse(id, { + content: result.content, + isError: result.isError ?? false, + details: result.details, + }); + } + catch (error) { + return successResponse(id, { + content: [{ type: "text", text: messageFromError(error) }], + isError: true, + }); + } +} +function describeTool(tool) { + return { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + }; +} +function successResponse(id, result) { + return { jsonrpc: "2.0", id, result }; +} +function errorResponse(id, code, message, data) { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} +function requestedProtocolVersion(params) { + if (!isRecord(params) || typeof params["protocolVersion"] !== "string") + return "2024-11-05"; + return params["protocolVersion"]; +} +function jsonRpcId(value) { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function messageFromError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/plugins/omo/components/lsp-tools-mcp/dist/tools.d.ts b/plugins/omo/components/lsp-tools-mcp/dist/tools.d.ts new file mode 100644 index 0000000..ef16e6c --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/tools.d.ts @@ -0,0 +1,90 @@ +import type { Diagnostic, DocumentSymbol, Location, LocationLink, PrepareRenameDefaultBehavior, PrepareRenameResult, Range, SeverityFilter, SymbolInfo, WorkspaceEdit } from "./lsp/types.js"; +import { type ApplyResult } from "./lsp/workspace-edit.js"; +export interface TextContent { + type: "text"; + text: string; +} +export interface ToolExecutionResult { + content: TextContent[]; + isError?: boolean; + details?: unknown; +} +export interface JsonSchema { + type: string; + description?: string; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: string[]; +} +export interface LspMcpTool { + name: string; + aliases?: string[]; + title: string; + description: string; + inputSchema: JsonSchema; + execute(params: Record, signal?: AbortSignal): Promise; +} +export interface LspDiagnosticsDetails { + filePath: string; + severity: SeverityFilter; + mode: "file" | "directory"; + diagnostics: Array<{ + file: string; + diagnostic: Diagnostic; + }>; + totalDiagnostics: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency" | "no_files" | "invalid_path"; +} +export interface LspGotoDefinitionDetails { + filePath: string; + line: number; + character: number; + locations: Array; + error?: string; + errorKind?: "missing_dependency"; +} +export interface LspFindReferencesDetails { + filePath: string; + line: number; + character: number; + references: Location[]; + totalReferences: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency"; +} +export interface LspSymbolsDetails { + filePath: string; + scope: "document" | "workspace"; + query?: string; + symbols: Array; + totalSymbols: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency" | "missing_query"; +} +export interface LspPrepareRenameDetails { + filePath: string; + line: number; + character: number; + result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null; + error?: string; + errorKind?: "missing_dependency"; +} +export interface LspRenameDetails { + filePath: string; + line: number; + character: number; + newName: string; + apply: ApplyResult | null; + edit: WorkspaceEdit | null; + error?: string; + errorKind?: "missing_dependency"; +} +export declare function executeLspDiagnostics(params: Record, signal?: AbortSignal): Promise; +export declare function executeLspTool(name: string, params: Record, signal?: AbortSignal): Promise; +export declare function coerceToolArguments(value: unknown): Record; +export declare const LSP_MCP_TOOLS: LspMcpTool[]; diff --git a/plugins/omo/components/lsp-tools-mcp/dist/tools.js b/plugins/omo/components/lsp-tools-mcp/dist/tools.js new file mode 100644 index 0000000..a486b4f --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/dist/tools.js @@ -0,0 +1,460 @@ +import { resolve } from "node:path"; +import { isDirectoryPath, withLspClient } from "./lsp/client-wrapper.js"; +import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES, DEFAULT_MAX_SYMBOLS } from "./lsp/constants.js"; +import { aggregateDiagnosticsForDirectory } from "./lsp/directory-diagnostics.js"; +import { filterDiagnosticsBySeverity, formatApplyResult, formatDiagnostic, formatDocumentSymbol, formatLocation, formatPrepareRenameResult, formatSymbolInfo, } from "./lsp/formatters.js"; +import { inferExtensionFromDirectory } from "./lsp/infer-extension.js"; +import { getLspManager } from "./lsp/manager.js"; +import { getAllServers } from "./lsp/server-resolution.js"; +import { handleMissingDependencyError } from "./lsp/utils.js"; +import { applyWorkspaceEdit } from "./lsp/workspace-edit.js"; +const objectSchema = (properties, required = []) => ({ + type: "object", + properties, + required, +}); +function text(text, details, isError = false) { + return { content: [{ type: "text", text }], details, isError }; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function requireString(params, key) { + const value = params[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing required string parameter '${key}'`); + } + return value; +} +function optionalString(params, key) { + const value = params[key]; + return typeof value === "string" ? value : undefined; +} +function requireNumber(params, key) { + const value = params[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Missing required number parameter '${key}'`); + } + return value; +} +function optionalNumber(params, key) { + const value = params[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} +function optionalBoolean(params, key) { + const value = params[key]; + return typeof value === "boolean" ? value : undefined; +} +function isSeverityFilter(value) { + return value === "error" || value === "warning" || value === "information" || value === "hint" || value === "all"; +} +function severityFilter(params) { + const value = params["severity"]; + if (isSeverityFilter(value)) + return value; + return "all"; +} +function clientOptions(signal) { + return signal === undefined ? {} : { signal }; +} +function asDiagnosticArray(result) { + if (!result) + return []; + if (Array.isArray(result)) + return result; + return result.items ?? []; +} +function isDocumentSymbol(symbol) { + return "range" in symbol; +} +async function executeLspStatus() { + const servers = getAllServers(); + const snapshots = getLspManager().getSnapshot(); + const installed = servers.filter((server) => server.installed && !server.disabled); + const configuredLines = servers.map((server) => { + const state = server.disabled ? "disabled" : server.installed ? "installed" : "missing"; + return `- ${server.id}: ${state}; source=${server.source}; extensions=${server.extensions.join(", ")}`; + }); + const activeLines = snapshots.map((snapshot) => { + const state = snapshot.alive ? (snapshot.isInitializing ? "initializing" : "alive") : "dead"; + return `- ${snapshot.serverId}: ${state}; root=${snapshot.root}; refs=${snapshot.refCount}`; + }); + const lines = [ + `Configured LSP servers: ${servers.length}`, + `Installed LSP servers: ${installed.length}`, + "", + ...configuredLines, + "", + `Active LSP clients: ${snapshots.length}`, + ...activeLines, + ]; + return text(lines.join("\n"), { servers, snapshots }); +} +export async function executeLspDiagnostics(params, signal) { + const filePath = requireString(params, "filePath"); + const severity = severityFilter(params); + try { + const absPath = resolve(filePath); + if (isDirectoryPath(absPath)) { + const extension = inferExtensionFromDirectory(absPath); + if (!extension) { + const message = `No supported source files found in directory: ${absPath}`; + const details = { + filePath, + severity, + mode: "directory", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + error: message, + errorKind: "no_files", + }; + return text(message, details); + } + const output = await aggregateDiagnosticsForDirectory(absPath, extension, severity); + const details = { + filePath, + severity, + mode: "directory", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + }; + return text(output, details); + } + const result = await withLspClient(filePath, async (client) => client.diagnostics(filePath), "diagnostics", clientOptions(signal)); + const diagnostics = filterDiagnosticsBySeverity(asDiagnosticArray(result), severity); + const total = diagnostics.length; + const truncated = total > DEFAULT_MAX_DIAGNOSTICS; + const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics; + const output = total === 0 + ? "No diagnostics found" + : [ + ...(truncated ? [`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`] : []), + ...limited.map(formatDiagnostic), + ].join("\n"); + const details = { + filePath, + severity, + mode: "file", + diagnostics: diagnostics.map((diagnostic) => ({ file: absPath, diagnostic })), + totalDiagnostics: total, + truncated, + }; + return text(output, details); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + const details = { + filePath, + severity, + mode: "file", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + }; + return text(message, details); + } + throw error; + } +} +async function executeLspGotoDefinition(params, signal) { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + try { + const result = await withLspClient(filePath, async (client) => client.definition(filePath, line, character), "definition", clientOptions(signal)); + const locations = !result ? [] : Array.isArray(result) ? result : [result]; + const details = { filePath, line, character, locations }; + if (locations.length === 0) + return text("No definition found", details); + return text(locations.map(formatLocation).join("\n"), details); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + locations: [], + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} +async function executeLspFindReferences(params, signal) { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + const includeDeclaration = optionalBoolean(params, "includeDeclaration") ?? true; + try { + const result = await withLspClient(filePath, async (client) => client.references(filePath, line, character, includeDeclaration), "references", clientOptions(signal)); + const references = Array.isArray(result) ? result : []; + const total = references.length; + const truncated = total > DEFAULT_MAX_REFERENCES; + const limited = truncated ? references.slice(0, DEFAULT_MAX_REFERENCES) : references; + const details = { + filePath, + line, + character, + references, + totalReferences: total, + truncated, + }; + if (total === 0) + return text("No references found", details); + const output = [ + ...(truncated ? [`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`] : []), + ...limited.map(formatLocation), + ].join("\n"); + return text(output, details); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + references: [], + totalReferences: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} +async function executeLspSymbols(params, signal) { + const filePath = requireString(params, "filePath"); + const rawScope = optionalString(params, "scope") ?? "document"; + const scope = rawScope === "workspace" ? "workspace" : "document"; + const limit = Math.min(optionalNumber(params, "limit") ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS); + try { + if (scope === "workspace") { + const query = optionalString(params, "query"); + if (!query) { + const message = "Error: 'query' is required for workspace scope"; + return text(message, { + filePath, + scope, + symbols: [], + totalSymbols: 0, + truncated: false, + error: message, + errorKind: "missing_query", + }); + } + const symbols = await withLspClient(filePath, async (client) => client.workspaceSymbols(query), "workspaceSymbols", clientOptions(signal)); + return formatSymbolsResult(filePath, scope, symbols, limit, query); + } + const symbols = await withLspClient(filePath, async (client) => client.documentSymbols(filePath), "documentSymbols", clientOptions(signal)); + return formatSymbolsResult(filePath, scope, symbols, limit); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + const query = optionalString(params, "query"); + return text(message, { + filePath, + scope, + symbols: [], + totalSymbols: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + ...(query === undefined ? {} : { query }), + }); + } + throw error; + } +} +function formatSymbolsResult(filePath, scope, symbols, limit, query) { + const total = symbols.length; + const truncated = total > limit; + const limited = truncated ? symbols.slice(0, limit) : symbols; + const details = { + filePath, + scope, + symbols, + totalSymbols: total, + truncated, + ...(query === undefined ? {} : { query }), + }; + if (total === 0) + return text("No symbols found", details); + const lines = []; + if (truncated) + lines.push(`Found ${total} symbols (showing first ${limit}):`); + const documentSymbols = limited.filter(isDocumentSymbol); + if (documentSymbols.length === limited.length) { + lines.push(...documentSymbols.map((symbol) => formatDocumentSymbol(symbol))); + } + else { + lines.push(...limited.filter((symbol) => !isDocumentSymbol(symbol)).map(formatSymbolInfo)); + } + return text(lines.join("\n"), details); +} +async function executeLspPrepareRename(params, signal) { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + try { + const result = await withLspClient(filePath, async (client) => client.prepareRename(filePath, line, character), "prepareRename", clientOptions(signal)); + const details = { filePath, line, character, result }; + return text(formatPrepareRenameResult(result), details); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + result: null, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} +async function executeLspRename(params, signal) { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + const newName = requireString(params, "newName"); + try { + const edit = await withLspClient(filePath, async (client) => client.rename(filePath, line, character, newName), "rename", clientOptions(signal)); + const apply = applyWorkspaceEdit(edit); + const details = { filePath, line, character, newName, apply, edit }; + return text(formatApplyResult(apply), details, !apply.success); + } + catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + newName, + apply: null, + edit: null, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} +export async function executeLspTool(name, params, signal) { + const tool = LSP_MCP_TOOLS.find((candidate) => matchesToolName(candidate, name)); + if (!tool) + throw new Error(`Unknown LSP tool: ${name}`); + return tool.execute(params, signal); +} +function matchesToolName(tool, name) { + return tool.name === name || (tool.aliases?.includes(name) ?? false); +} +export function coerceToolArguments(value) { + return isRecord(value) ? value : {}; +} +export const LSP_MCP_TOOLS = [ + { + name: "status", + aliases: ["lsp_status"], + title: "LSP Status", + description: "List configured and active LSP servers without starting a new language server.", + inputSchema: objectSchema({}), + execute: executeLspStatus, + }, + { + name: "diagnostics", + aliases: ["lsp_diagnostics"], + title: "LSP Diagnostics", + description: "Get errors, warnings, and hints for a source file or directory.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "File or directory path to check." }, + severity: { + type: "string", + enum: ["error", "warning", "information", "hint", "all"], + description: "Severity filter. Defaults to all.", + }, + }, ["filePath"]), + execute: executeLspDiagnostics, + }, + { + name: "goto_definition", + aliases: ["lsp_goto_definition"], + title: "LSP Goto Definition", + description: "Find where a symbol is defined.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "Source file containing the symbol." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + }, ["filePath", "line", "character"]), + execute: executeLspGotoDefinition, + }, + { + name: "find_references", + aliases: ["lsp_find_references"], + title: "LSP Find References", + description: "Find references of a symbol across the workspace.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "Source file containing the symbol." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + includeDeclaration: { type: "boolean", description: "Include the declaration. Defaults to true." }, + }, ["filePath", "line", "character"]), + execute: executeLspFindReferences, + }, + { + name: "symbols", + aliases: ["lsp_symbols"], + title: "LSP Symbols", + description: "List document symbols or search workspace symbols.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "File path used as LSP context." }, + scope: { + type: "string", + enum: ["document", "workspace"], + description: "Use document for file outline or workspace for project-wide search.", + }, + query: { type: "string", description: "Workspace symbol query." }, + limit: { type: "number", description: "Maximum number of symbols to return." }, + }, ["filePath", "scope"]), + execute: executeLspSymbols, + }, + { + name: "prepare_rename", + aliases: ["lsp_prepare_rename"], + title: "LSP Prepare Rename", + description: "Check whether a symbol can be renamed at a position.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "Source file path." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + }, ["filePath", "line", "character"]), + execute: executeLspPrepareRename, + }, + { + name: "rename", + aliases: ["lsp_rename"], + title: "LSP Rename", + description: "Rename a symbol across the workspace and apply the returned workspace edit.", + inputSchema: objectSchema({ + filePath: { type: "string", description: "Source file path." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + newName: { type: "string", description: "New symbol name." }, + }, ["filePath", "line", "character", "newName"]), + execute: executeLspRename, + }, +]; diff --git a/plugins/omo/components/lsp-tools-mcp/package.json b/plugins/omo/components/lsp-tools-mcp/package.json new file mode 100644 index 0000000..297f936 --- /dev/null +++ b/plugins/omo/components/lsp-tools-mcp/package.json @@ -0,0 +1,37 @@ +{ + "name": "@code-yeongyu/lsp-tools-mcp", + "version": "0.1.0", + "description": "Standalone Language Server Protocol tools exposed as a stdio MCP server.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/lsp-tools-mcp", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/lsp-tools-mcp.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/lsp-tools-mcp/issues" + }, + "keywords": [ + "mcp", + "lsp", + "language-server-protocol", + "model-context-protocol", + "typescript", + "nodejs" + ], + "bin": { + "lsp-tools-mcp": "./dist/cli.js" + }, + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "engines": { + "node": ">=20.0.0" + } +} diff --git a/plugins/omo/components/lsp/.mcp.json b/plugins/omo/components/lsp/.mcp.json index 46ecc49..be45df8 100644 --- a/plugins/omo/components/lsp/.mcp.json +++ b/plugins/omo/components/lsp/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "lsp": { "command": "node", - "args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"], + "args": ["../lsp-tools-mcp/dist/cli.js", "mcp"], "cwd": "." } } diff --git a/plugins/omo/components/lsp/CHANGELOG.md b/plugins/omo/components/lsp/CHANGELOG.md index b2efa7e..242fd53 100644 --- a/plugins/omo/components/lsp/CHANGELOG.md +++ b/plugins/omo/components/lsp/CHANGELOG.md @@ -4,6 +4,10 @@ - Reuse the repository-level `packages/lsp-tools-mcp` package instead of carrying a second copy under `components/lsp/packages`. +## 0.2.1 + +- Resolve the LSP runtime from the aggregate plugin bundle so installed LazyCodex plugin caches do not depend on files outside `plugins/omo`. + ## 0.2.0 - Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp). diff --git a/plugins/omo/components/lsp/README.md b/plugins/omo/components/lsp/README.md index a0ca81c..4c0ad7b 100644 --- a/plugins/omo/components/lsp/README.md +++ b/plugins/omo/components/lsp/README.md @@ -75,7 +75,7 @@ The plugin ships: - `hooks/hooks.json` for the `PostToolUse` diagnostics hook. - `skills/lsp/SKILL.md` with MCP usage guidance. -The runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:../../../../lsp-tools-mcp`, so marketplace builds reuse the root package instead of carrying a second copy under this component. +The aggregate Codex plugin bundles the runtime under `components/lsp-tools-mcp` and this component depends on it via `file:../lsp-tools-mcp`, so installed plugin caches can resolve the LSP hook and MCP runtime without files outside the plugin bundle. The hook command is: @@ -86,13 +86,13 @@ node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use The MCP command is: ```bash -node ../../../../lsp-tools-mcp/dist/cli.js mcp +node ../lsp-tools-mcp/dist/cli.js mcp ``` ## Local Development ```bash -npm run bootstrap # installs + builds the root packages/lsp-tools-mcp package +npm run bootstrap # installs + builds the bundled lsp-tools-mcp package when developing from source npm install npm test npm run typecheck diff --git a/plugins/omo/components/lsp/package.json b/plugins/omo/components/lsp/package.json index b6a9af5..0df071f 100644 --- a/plugins/omo/components/lsp/package.json +++ b/plugins/omo/components/lsp/package.json @@ -50,7 +50,7 @@ "check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json" }, "dependencies": { - "@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp" + "@code-yeongyu/lsp-tools-mcp": "file:../lsp-tools-mcp" }, "devDependencies": { "@biomejs/biome": "2.4.15", diff --git a/plugins/omo/components/lsp/test/package-smoke.test.ts b/plugins/omo/components/lsp/test/package-smoke.test.ts index 72c0247..f48d357 100644 --- a/plugins/omo/components/lsp/test/package-smoke.test.ts +++ b/plugins/omo/components/lsp/test/package-smoke.test.ts @@ -70,7 +70,7 @@ describe("plugin package metadata", () => { expect(packageJson.type).toBe("module"); expect(packageJson.packageManager).toBe("npm@11.12.1"); expect(packageJson.dependencies).toEqual({ - "@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp", + "@code-yeongyu/lsp-tools-mcp": "file:../lsp-tools-mcp", }); expect(packageJson.bin["omo-lsp"]).toBe("./dist/cli.js"); expect(packageJson.bin["codex-lsp"]).toBeUndefined(); @@ -80,7 +80,7 @@ describe("plugin package metadata", () => { expect(postToolUseCommand).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`); expect(postCompactCommand).toBe(`node "${pluginRoot}/dist/cli.js" hook post-compact`); expect(lspServer?.command).toBe("node"); - expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]); + expect(lspServer?.args).toEqual(["../lsp-tools-mcp/dist/cli.js", "mcp"]); expect(cliSource).not.toContain("./lazy-lsp-mcp.js"); expect(cliSource).toContain("@code-yeongyu/lsp-tools-mcp/dist/cli.js"); expect(cliSource).not.toContain("../../../../../lsp-tools-mcp/dist/cli.js"); diff --git a/plugins/omo/package-lock.json b/plugins/omo/package-lock.json index def5a31..f4d6ab3 100644 --- a/plugins/omo/package-lock.json +++ b/plugins/omo/package-lock.json @@ -12,6 +12,7 @@ "components/git-bash", "components/rules", "components/lsp", + "components/lsp-tools-mcp", "components/telemetry", "components/start-work-continuation", "components/ulw-loop", @@ -21,23 +22,6 @@ "@oh-my-opencode/shared-skills": "file:../../shared-skills" } }, - "../../lsp-tools-mcp": { - "name": "@code-yeongyu/lsp-tools-mcp", - "version": "0.1.0", - "license": "MIT", - "bin": { - "omo-lsp": "dist/cli.js" - }, - "devDependencies": { - "@biomejs/biome": "2.4.15", - "@types/node": "^25.7.0", - "typescript": "^6.0.3", - "vitest": "^4.1.5" - }, - "engines": { - "node": ">=20.0.0" - } - }, "../../shared-skills": { "name": "@oh-my-opencode/shared-skills", "version": "0.1.0" @@ -81,7 +65,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp" + "@code-yeongyu/lsp-tools-mcp": "file:../lsp-tools-mcp" }, "bin": { "omo-lsp": "dist/cli.js" @@ -406,7 +390,7 @@ } }, "node_modules/@code-yeongyu/lsp-tools-mcp": { - "resolved": "../../lsp-tools-mcp", + "resolved": "components/lsp-tools-mcp", "link": true }, "node_modules/@emnapi/core": { @@ -1764,6 +1748,17 @@ "engines": { "node": ">=8" } + }, + "components/lsp-tools-mcp": { + "name": "@code-yeongyu/lsp-tools-mcp", + "version": "0.1.0", + "license": "MIT", + "bin": { + "lsp-tools-mcp": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } } } } diff --git a/plugins/omo/package.json b/plugins/omo/package.json index b997a24..9c7da02 100644 --- a/plugins/omo/package.json +++ b/plugins/omo/package.json @@ -10,6 +10,7 @@ "components/git-bash", "components/rules", "components/lsp", + "components/lsp-tools-mcp", "components/telemetry", "components/start-work-continuation", "components/ulw-loop", diff --git a/plugins/omo/test/aggregate-hooks.test.mjs b/plugins/omo/test/aggregate-hooks.test.mjs index 6792e54..dee67a3 100644 --- a/plugins/omo/test/aggregate-hooks.test.mjs +++ b/plugins/omo/test/aggregate-hooks.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; import test from "node:test"; import { @@ -100,6 +100,31 @@ test("#given aggregate hook commands #when packaging is verified #then reference assert.deepEqual(missingComponentCliTargets, []); }); +test("#given component file dependencies #when packaging is verified #then dependency targets stay inside the plugin bundle", async () => { + // given + const packageJson = await readJson("package.json"); + const workspacePackagePaths = packageJson.workspaces.map((workspace) => join(workspace, "package.json")); + const pluginRoot = resolve(root); + const pluginRootPrefix = pluginRoot.endsWith(sep) ? pluginRoot : `${pluginRoot}${sep}`; + + // when + const escapingDependencies = []; + for (const workspacePackagePath of workspacePackagePaths) { + const workspacePackage = await readJson(workspacePackagePath); + const workspaceRoot = dirname(resolve(root, workspacePackagePath)); + for (const [name, specifier] of Object.entries(workspacePackage.dependencies ?? {})) { + if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue; + const target = resolve(workspaceRoot, specifier.slice("file:".length)); + if (target !== pluginRoot && !target.startsWith(pluginRootPrefix)) { + escapingDependencies.push(`${workspacePackagePath}:${name}:${specifier}`); + } + } + } + + // then + assert.deepEqual(escapingDependencies, []); +}); + test("#given component hook commands #when inspected #then standalone packages expose Codex status messages", async () => { // given const componentHooks = await readComponentHookManifests(); diff --git a/plugins/omo/test/aggregate-mcp.test.mjs b/plugins/omo/test/aggregate-mcp.test.mjs index ff26221..26c1fdd 100644 --- a/plugins/omo/test/aggregate-mcp.test.mjs +++ b/plugins/omo/test/aggregate-mcp.test.mjs @@ -5,7 +5,11 @@ import test from "node:test"; import { exists, readJson, root } from "./aggregate-plugin-fixture.mjs"; -const mcpPackageManifestPaths = ["../../lsp-tools-mcp/package.json", "../../ast-grep-mcp/package.json", "../../git-bash-mcp/package.json"]; +const mcpPackageManifestPaths = [ + "components/lsp-tools-mcp/package.json", + "components/ast-grep-mcp/package.json", + "components/git-bash-mcp/package.json", +]; const mcpPackageManifestExists = await Promise.all(mcpPackageManifestPaths.map(exists)); test("#given aggregate MCP config #when inspected #then code MCPs reference package runtimes without package names", async () => { @@ -33,13 +37,13 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack assert.match(bundledMcpBuildScript, /git-bash-mcp/); assert.doesNotMatch(packageJson.scripts.build, /--workspaces/); assert.equal(lspServer.command, "node"); - assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]); + assert.deepEqual(lspServer.args, ["./components/lsp-tools-mcp/dist/cli.js", "mcp"]); assert.equal(lspServer.cwd, "."); assert.equal(astGrepServer.command, "node"); - assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); + assert.deepEqual(astGrepServer.args, ["./components/ast-grep-mcp/dist/cli.js", "mcp"]); assert.equal(astGrepServer.cwd, "."); assert.equal(gitBashServer.command, "node"); - assert.deepEqual(gitBashServer.args, ["../../git-bash-mcp/dist/cli.js", "mcp"]); + assert.deepEqual(gitBashServer.args, ["./components/git-bash-mcp/dist/cli.js", "mcp"]); assert.equal(gitBashServer.cwd, "."); assert.deepEqual(componentLocalMcpSources, []); }); From 02ea2f718e0faa16ee20363c5e6305aad1d05eac Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 06:52:01 +0000 Subject: [PATCH 3/6] fix: align bundled MCP runtime build --- .../scripts/build-bundled-mcp-runtimes.mjs | 34 ++++++++++++------- plugins/omo/test/aggregate-manifest.test.mjs | 1 + .../omo/test/mcp-research-servers.test.mjs | 2 +- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs index b3ee50c..c56b746 100644 --- a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs +++ b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs @@ -6,21 +6,25 @@ import { fileURLToPath } from "node:url"; const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url))); const repoPackagesRoot = join(pluginRoot, "..", ".."); +const bundledComponentsRoot = join(pluginRoot, "components"); const runtimes = [ { label: "lsp-tools-mcp", - packageRoot: join(repoPackagesRoot, "lsp-tools-mcp"), + sourceRoot: join(repoPackagesRoot, "lsp-tools-mcp"), + bundledRoot: join(bundledComponentsRoot, "lsp-tools-mcp"), requiredOutputs: ["dist/cli.js", "dist/tools.js"], }, { label: "ast-grep-mcp", - packageRoot: join(repoPackagesRoot, "ast-grep-mcp"), + sourceRoot: join(repoPackagesRoot, "ast-grep-mcp"), + bundledRoot: join(bundledComponentsRoot, "ast-grep-mcp"), requiredOutputs: ["dist/cli.js"], }, { label: "git-bash-mcp", - packageRoot: join(repoPackagesRoot, "git-bash-mcp"), + sourceRoot: join(repoPackagesRoot, "git-bash-mcp"), + bundledRoot: join(bundledComponentsRoot, "git-bash-mcp"), requiredOutputs: ["dist/cli.js"], }, ]; @@ -30,19 +34,23 @@ for (const runtime of runtimes) { } function buildRuntime(runtime) { - if (hasBundledDist(runtime)) { + if (hasRequiredOutputs(runtime.bundledRoot, runtime)) { console.log(`Using bundled ${runtime.label} dist`); return; } - if (!existsSync(join(runtime.packageRoot, "package.json"))) { - assertBundledDist(runtime); - console.log(`Using bundled ${runtime.label} dist`); + if (existsSync(join(runtime.bundledRoot, "package.json"))) { + assertRequiredOutputs(runtime.bundledRoot, runtime); + return; + } + + if (!existsSync(join(runtime.sourceRoot, "package.json"))) { + console.warn(`Skipping ${runtime.label}; no source package or bundled runtime found`); return; } const result = spawnSync("npm", ["run", "build"], { - cwd: runtime.packageRoot, + cwd: runtime.sourceRoot, shell: process.platform === "win32", stdio: "inherit", }); @@ -50,16 +58,16 @@ function buildRuntime(runtime) { if (result.status !== 0) process.exit(result.status ?? 1); } -function hasBundledDist(runtime) { - return runtime.requiredOutputs.every((output) => existsSync(join(runtime.packageRoot, output))); +function hasRequiredOutputs(root, runtime) { + return runtime.requiredOutputs.every((output) => existsSync(join(root, output))); } -function assertBundledDist(runtime) { - const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output))); +function assertRequiredOutputs(root, runtime) { + const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(root, output))); if (missingOutputs.length === 0) return; console.error(`Missing bundled ${runtime.label} outputs:`); for (const output of missingOutputs) { - console.error(` ${join(runtime.packageRoot, output)}`); + console.error(` ${join(root, output)}`); } process.exit(1); } diff --git a/plugins/omo/test/aggregate-manifest.test.mjs b/plugins/omo/test/aggregate-manifest.test.mjs index 321a1d0..d4737ea 100644 --- a/plugins/omo/test/aggregate-manifest.test.mjs +++ b/plugins/omo/test/aggregate-manifest.test.mjs @@ -52,6 +52,7 @@ test("#given component directories #when scanned #then only intentional resource "comment-checker", "git-bash", "lsp", + "lsp-tools-mcp", "rules", "start-work-continuation", "telemetry", diff --git a/plugins/omo/test/mcp-research-servers.test.mjs b/plugins/omo/test/mcp-research-servers.test.mjs index c56cb7f..264a81a 100644 --- a/plugins/omo/test/mcp-research-servers.test.mjs +++ b/plugins/omo/test/mcp-research-servers.test.mjs @@ -17,5 +17,5 @@ test("#given aggregate MCP config #when inspected #then registers research and s assert.deepEqual(serverNames, ["ast_grep", "context7", "git_bash", "grep_app", "lsp"]); assert.equal(mcp.mcpServers.grep_app.url, "https://mcp.grep.app"); assert.equal(mcp.mcpServers.context7.url, "https://mcp.context7.com/mcp"); - assert.deepEqual(mcp.mcpServers.ast_grep.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); + assert.deepEqual(mcp.mcpServers.ast_grep.args, ["./components/ast-grep-mcp/dist/cli.js", "mcp"]); }); From 3a28a1f393db2dd899bea71353c0246960994866 Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 07:25:24 +0000 Subject: [PATCH 4/6] fix: bundle configured OMO MCP runtimes --- plugins/omo/.mcp.json | 8 - plugins/omo/README.md | 3 +- .../omo/components/ast-grep-mcp/dist/cli.js | 988 ++++++++++++++++++ .../omo/components/ast-grep-mcp/package.json | 17 + plugins/omo/hooks/hooks.json | 22 - .../scripts/build-bundled-mcp-runtimes.mjs | 8 +- plugins/omo/test/aggregate-hooks.test.mjs | 10 +- plugins/omo/test/aggregate-manifest.test.mjs | 1 + plugins/omo/test/aggregate-mcp.test.mjs | 39 +- .../omo/test/mcp-research-servers.test.mjs | 2 +- 10 files changed, 1038 insertions(+), 60 deletions(-) create mode 100644 plugins/omo/components/ast-grep-mcp/dist/cli.js create mode 100644 plugins/omo/components/ast-grep-mcp/package.json diff --git a/plugins/omo/.mcp.json b/plugins/omo/.mcp.json index 45867b2..5b2279e 100644 --- a/plugins/omo/.mcp.json +++ b/plugins/omo/.mcp.json @@ -14,14 +14,6 @@ "context7": { "url": "https://mcp.context7.com/mcp" }, - "git_bash": { - "command": "node", - "args": [ - "./components/git-bash-mcp/dist/cli.js", - "mcp" - ], - "cwd": "." - }, "lsp": { "command": "node", "args": [ diff --git a/plugins/omo/README.md b/plugins/omo/README.md index c2d306d..9c82406 100644 --- a/plugins/omo/README.md +++ b/plugins/omo/README.md @@ -5,6 +5,7 @@ Internally each component remains isolated under `components/`: - `components/comment-checker` +- `components/ast-grep-mcp` - `components/rules` - `components/lsp` - `components/git-bash` @@ -13,7 +14,7 @@ Internally each component remains isolated under `components/`: - `components/ulw-loop` - `components/telemetry` -The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and plugin-scoped MCP servers for `ast_grep`, `grep_app`, `context7`, `git_bash`, and `lsp`. +The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and plugin-scoped MCP servers for `ast_grep`, `grep_app`, `context7`, and `lsp`. ## Telemetry diff --git a/plugins/omo/components/ast-grep-mcp/dist/cli.js b/plugins/omo/components/ast-grep-mcp/dist/cli.js new file mode 100644 index 0000000..1c02c01 --- /dev/null +++ b/plugins/omo/components/ast-grep-mcp/dist/cli.js @@ -0,0 +1,988 @@ +#!/usr/bin/env node + +// src/cli.ts +import { argv, stderr } from "node:process"; + +// src/mcp-lifecycle-log.ts +import { appendFileSync, renameSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +var LOG_FILE_NAME = "omo-ast-grep-mcp.log"; +var MAX_LOG_BYTES = 5 * 1024 * 1024; +function mcpLifecycleLogPath() { + return join(tmpdir(), LOG_FILE_NAME); +} +function writeMcpLifecycleLog(event, fields = {}) { + const path = mcpLifecycleLogPath(); + try { + rotateLogIfNeeded(path); + appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })} +`); + } catch (error) { + if (error instanceof Error) + return; + return; + } +} +function rotateLogIfNeeded(path) { + try { + if (statSync(path).size < MAX_LOG_BYTES) + return; + renameSync(path, `${path}.1`); + } catch (error) { + if (error instanceof Error) + return; + return; + } +} + +// ../ast-grep-core/src/language-support.ts +var CLI_LANGUAGES = [ + "bash", + "c", + "cpp", + "csharp", + "css", + "elixir", + "go", + "haskell", + "html", + "java", + "javascript", + "json", + "kotlin", + "lua", + "nix", + "php", + "python", + "ruby", + "rust", + "scala", + "solidity", + "swift", + "typescript", + "tsx", + "yaml" +]; +var DEFAULT_TIMEOUT_MS = 300000; +var DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024; +var DEFAULT_MAX_MATCHES = 500; +// ../ast-grep-core/src/pattern-hints.ts +function detectRegexMisuse(pattern) { + const src = pattern.trim(); + if (/\\[wWdDsSbB]/.test(src)) { + return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.'; + } + if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) { + return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.'; + } + if (!src.includes("$") && /\w\.[*+]/.test(src)) { + return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.'; + } + if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) { + return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".'; + } + return null; +} +function detectLanguageSpecificMistake(pattern, lang) { + const src = pattern.trim(); + if (lang === "python") { + if (src.startsWith("class ") && src.endsWith(":")) { + return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`; + } + if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) { + return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`; + } + } + if (["javascript", "typescript", "tsx"].includes(lang)) { + if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"'; + } + } + if (lang === "go") { + if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"'; + } + } + if (lang === "rust") { + if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) { + return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"'; + } + } + return null; +} +function getPatternHint(pattern, lang) { + return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang); +} +// ../ast-grep-core/src/result-formatter.ts +function formatSearchResult(result) { + if (result.error) { + return `Error: ${result.error}`; + } + if (result.matches.length === 0) { + return "No matches found"; + } + const lines = []; + if (result.truncated) { + const reason = result.truncatedReason === "max_matches" ? `showing first ${result.matches.length} of ${result.totalMatches}` : result.truncatedReason === "max_output_bytes" ? "output exceeded 1MB limit" : "search timed out"; + lines.push(`[TRUNCATED] Results truncated (${reason}) +`); + } + lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}: +`); + for (const match of result.matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`; + lines.push(`${loc}`); + lines.push(` ${match.lines.trim()}`); + lines.push(""); + } + return lines.join(` +`); +} +function formatReplaceResult(result, isDryRun) { + if (result.error) { + return `Error: ${result.error}`; + } + if (result.matches.length === 0) { + return "No matches found to replace"; + } + const prefix = isDryRun ? "[DRY RUN] " : ""; + const lines = []; + if (result.truncated) { + const reason = result.truncatedReason === "max_matches" ? `showing first ${result.matches.length} of ${result.totalMatches}` : result.truncatedReason === "max_output_bytes" ? "output exceeded 1MB limit" : "search timed out"; + lines.push(`[TRUNCATED] Results truncated (${reason}) +`); + } + lines.push(`${prefix}${result.matches.length} replacement(s): +`); + for (const match of result.matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`; + lines.push(`${loc}`); + lines.push(` ${match.text}`); + lines.push(""); + } + if (isDryRun) { + lines.push("Use dryRun=false to apply changes"); + } + return lines.join(` +`); +} +// ../ast-grep-core/src/sg-compact-json-output.ts +function createSgResultFromStdout(stdout) { + if (!stdout.trim()) { + return { matches: [], totalMatches: 0, truncated: false }; + } + const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES; + const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout; + let matches = []; + try { + matches = JSON.parse(outputToProcess); + } catch { + if (outputTruncated) { + try { + const lastValidIndex = outputToProcess.lastIndexOf("}"); + if (lastValidIndex > 0) { + const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex); + if (bracketIndex > 0) { + const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"; + matches = JSON.parse(truncatedJson); + } + } + } catch { + return { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: "max_output_bytes", + error: "Output too large and could not be parsed" + }; + } + } else { + return { matches: [], totalMatches: 0, truncated: false }; + } + } + const totalMatches = matches.length; + const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES; + const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches; + return { + matches: finalMatches, + totalMatches, + truncated: outputTruncated || matchesTruncated, + truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined + }; +} +// ../ast-grep-core/src/runner.ts +var SG_BINARY_NOT_FOUND_MESSAGE = `ast-grep (sg) binary not found. + +` + `Install options: +` + ` bun add -D @ast-grep/cli +` + ` cargo install ast-grep --locked +` + ` brew install ast-grep`; +function buildSgArgs(options, flags) { + const args = ["run", "-p", options.pattern, "--lang", options.lang]; + if (flags.includeJson) { + args.push("--json=compact"); + } + if (options.rewrite) { + args.push("-r", options.rewrite); + if (flags.includeUpdateAll) { + args.push("--update-all"); + } + } + if (typeof options.context === "number" && options.context > 0) { + args.push("-C", String(options.context)); + } + if (options.globs) { + for (const glob of options.globs) { + args.push("--globs", glob); + } + } + const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]; + args.push("--", ...paths); + return args; +} +async function runSg(options, deps) { + const shouldSeparateWritePass = Boolean(options.rewrite && options.updateAll); + const args = buildSgArgs(options, { includeJson: true, includeUpdateAll: false }); + let binary; + try { + binary = await deps.resolveBinary(); + } catch (error) { + return { + matches: [], + totalMatches: 0, + truncated: false, + error: isNoEntryError(error) ? SG_BINARY_NOT_FOUND_MESSAGE : `Failed to resolve ast-grep binary: ${errorMessage(error)}` + }; + } + const searchResult = await trySpawn(binary, args, options.cwd, deps); + if (searchResult.error) { + return searchResult.error; + } + const output = searchResult.value; + if (output.exitCode !== 0 && output.stdout.trim() === "") { + if (output.stderr.includes("No files found")) { + return { matches: [], totalMatches: 0, truncated: false }; + } + if (output.stderr.trim()) { + return { matches: [], totalMatches: 0, truncated: false, error: output.stderr.trim() }; + } + return { matches: [], totalMatches: 0, truncated: false }; + } + const jsonResult = createSgResultFromStdout(output.stdout); + if (!(shouldSeparateWritePass && jsonResult.matches.length > 0)) { + return jsonResult; + } + const writeArgs = buildSgArgs(options, { includeJson: false, includeUpdateAll: true }); + const writeResult = await trySpawn(binary, writeArgs, options.cwd, deps); + if (writeResult.error) { + return { ...jsonResult, error: `Replace failed: ${writeResult.error.error ?? "unknown error"}` }; + } + if (writeResult.value.exitCode !== 0) { + const errorDetail = writeResult.value.stderr.trim() || `ast-grep exited with code ${writeResult.value.exitCode}`; + return { ...jsonResult, error: `Replace failed: ${errorDetail}` }; + } + return jsonResult; +} +async function trySpawn(binary, args, cwd, deps) { + try { + const value = await deps.spawnProcess(binary, args, { + cwd, + stdout: "pipe", + stderr: "pipe" + }); + return { value }; + } catch (error) { + if (error instanceof Error && error.message.includes("timeout")) { + return { + error: { + matches: [], + totalMatches: 0, + truncated: true, + truncatedReason: "timeout", + error: error.message + } + }; + } + if (isNoEntryError(error)) { + return { + error: { + matches: [], + totalMatches: 0, + truncated: false, + error: SG_BINARY_NOT_FOUND_MESSAGE + } + }; + } + return { + error: { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to spawn ast-grep: ${errorMessage(error)}` + } + }; + } +} +function isNoEntryError(error) { + if (typeof error !== "object" || error === null) { + return false; + } + const code = Reflect.get(error, "code"); + const message = errorMessage(error); + return code === "ENOENT" || message.includes("ENOENT") || message.includes("not found"); +} +function errorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} +// src/sg-cli-path.ts +import { createRequire } from "module"; +import { dirname, join as join2 } from "path"; +import { existsSync, statSync as statSync2 } from "fs"; +function isValidBinary(filePath) { + try { + return statSync2(filePath).size > 1e4; + } catch { + return false; + } +} +function getPlatformPackageName() { + const platform = process.platform; + const arch = process.arch; + const platformMap = { + "darwin-arm64": "@ast-grep/cli-darwin-arm64", + "darwin-x64": "@ast-grep/cli-darwin-x64", + "linux-arm64": "@ast-grep/cli-linux-arm64-gnu", + "linux-x64": "@ast-grep/cli-linux-x64-gnu", + "win32-x64": "@ast-grep/cli-win32-x64-msvc", + "win32-arm64": "@ast-grep/cli-win32-arm64-msvc", + "win32-ia32": "@ast-grep/cli-win32-ia32-msvc" + }; + return platformMap[`${platform}-${arch}`] ?? null; +} +function findSgCliPathSync() { + const binaryName = process.platform === "win32" ? "sg.exe" : "sg"; + try { + const require2 = createRequire(import.meta.url); + const cliPackageJsonPath = require2.resolve("@ast-grep/cli/package.json"); + const cliDirectory = dirname(cliPackageJsonPath); + const sgPath = join2(cliDirectory, binaryName); + if (existsSync(sgPath) && isValidBinary(sgPath)) { + return sgPath; + } + } catch {} + const platformPackage = getPlatformPackageName(); + if (platformPackage) { + try { + const require2 = createRequire(import.meta.url); + const packageJsonPath = require2.resolve(`${platformPackage}/package.json`); + const packageDirectory = dirname(packageJsonPath); + const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"; + const binaryPath = join2(packageDirectory, astGrepBinaryName); + if (existsSync(binaryPath) && isValidBinary(binaryPath)) { + return binaryPath; + } + } catch {} + } + if (process.platform === "darwin") { + const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]; + for (const path of homebrewPaths) { + if (existsSync(path) && isValidBinary(path)) { + return path; + } + } + } + return null; +} +var resolvedCliPath = null; +function getSgCliPath() { + if (resolvedCliPath !== null) { + return resolvedCliPath; + } + const syncPath = findSgCliPathSync(); + if (syncPath) { + resolvedCliPath = syncPath; + return syncPath; + } + return null; +} +function setSgCliPath(path) { + resolvedCliPath = path; +} +// src/mcp-stdio-server.ts +import { createInterface } from "node:readline"; +var DEFAULT_IDLE_TIMEOUT_MS = 10 * 60000; +var noopLog = () => {}; +async function runJsonRpcStdioServer(handler, input, output, options, stdioOptions = {}) { + const log = stdioOptions.log ?? noopLog; + const idleTimeoutMs = stdioOptions.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const idleTimer = createIdleTimer(idleTimeoutMs, log, stdioOptions.onIdleTimeout); + log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs }); + idleTimer.arm(); + const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + try { + for await (const line of lines) { + if (idleTimer.closed()) + break; + idleTimer.arm(); + if (!line.trim()) + continue; + const parsed = parseJsonRpcLine(line, output, log); + if (parsed === undefined) + continue; + const id = isRecord(parsed) ? jsonRpcId(parsed.id) : null; + const method = isRecord(parsed) && typeof parsed.method === "string" ? parsed.method : null; + log("request", { id: id === null ? null : String(id), method }); + const response = await handler(parsed, options); + if (response) { + output.write(`${JSON.stringify(response)} +`); + log("response", { id: String(response.id), method, is_error: response.error !== undefined }); + } + } + } finally { + idleTimer.clear(); + log("stdio_stopped"); + } +} +function createIdleTimer(idleTimeoutMs, log, onIdleTimeout) { + let timer = null; + let isClosed = false; + return { + arm: () => { + if (timer !== null) + clearTimeout(timer); + if (idleTimeoutMs <= 0) + return; + timer = setTimeout(() => { + isClosed = true; + log("idle_timeout", { idle_timeout_ms: idleTimeoutMs }); + onIdleTimeout?.(); + }, idleTimeoutMs); + timer.unref(); + }, + clear: () => { + if (timer === null) + return; + clearTimeout(timer); + timer = null; + }, + closed: () => isClosed + }; +} +function parseJsonRpcLine(line, output, log) { + try { + return JSON.parse(line); + } catch (error) { + const message = messageFromError(error); + log("parse_error", { message }); + output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", message))} +`); + return; + } +} +function errorResponse(id, code, message, data) { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} +function jsonRpcId(value) { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function messageFromError(error) { + return error instanceof Error ? error.message : String(error); +} +// src/runner.ts +import { existsSync as existsSync3 } from "node:fs"; + +// src/bun-spawn-shim.ts +import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"; +import { Writable } from "node:stream"; +var runtime = globalThis; +var IS_BUN = typeof runtime.Bun !== "undefined"; +function emptyReadableStream() { + return new ReadableStream({ + start(controller) { + controller.close(); + } + }); +} +function toReadableStream(stream) { + if (!stream) + return emptyReadableStream(); + return new ReadableStream({ + async start(controller) { + try { + for await (const chunk of stream) { + controller.enqueue(toUint8Array(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); + } + } + }); +} +function toUint8Array(chunk) { + if (chunk instanceof Uint8Array) + return new Uint8Array(chunk); + return new TextEncoder().encode(String(chunk)); +} +function emptyWritableStream() { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); +} +function isOptionsWithCommand(value) { + return typeof value === "object" && value !== null && "cmd" in value && Array.isArray(value.cmd); +} +function resolveCommand(cmdOrOpts, optsArg) { + if (isOptionsWithCommand(cmdOrOpts)) + return { cmd: cmdOrOpts.cmd, opts: cmdOrOpts }; + return { cmd: cmdOrOpts, opts: optsArg ?? {} }; +} +function resolveStdio(options) { + if (options.stdio) + return options.stdio; + return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"]; +} +function wrapNodeProcess(proc) { + let exitCode = null; + const exited = new Promise((resolve, reject) => { + proc.on("exit", (code) => { + exitCode = code ?? 1; + resolve(exitCode); + }); + proc.on("error", (error) => { + if (exitCode === null) { + exitCode = 1; + reject(error); + } + }); + }); + return { + get exitCode() { + return exitCode; + }, + exited, + stdout: toReadableStream(proc.stdout), + stderr: toReadableStream(proc.stderr), + stdin: proc.stdin ?? emptyWritableStream(), + pid: proc.pid, + kill(signal) { + if (proc.killed || exitCode !== null) + return; + proc.kill(signal); + }, + ref() { + proc.ref(); + }, + unref() { + proc.unref(); + } + }; +} +function wrapBunProcess(proc) { + return { + ...proc, + stdout: proc.stdout ?? emptyReadableStream(), + stderr: proc.stderr ?? emptyReadableStream() + }; +} +function spawn(cmdOrOpts, opts) { + const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts); + if (IS_BUN) + return wrapBunProcess(runtime.Bun.spawn(cmd, options)); + const [bin, ...args] = cmd; + if (!bin) + throw new Error("spawn requires a command"); + return wrapNodeProcess(nodeSpawn(bin, args, { + cwd: options.cwd, + env: options.env, + stdio: resolveStdio(options), + detached: options.detached, + signal: options.signal + })); +} + +// src/cli-binary-path-resolution.ts +import { existsSync as existsSync2 } from "fs"; +var resolvedCliPath2 = null; +var initPromise = null; +async function getAstGrepPath() { + if (resolvedCliPath2 !== null && existsSync2(resolvedCliPath2)) { + return resolvedCliPath2; + } + if (initPromise) { + return initPromise; + } + initPromise = (async () => { + const syncPath = findSgCliPathSync(); + if (syncPath && existsSync2(syncPath)) { + resolvedCliPath2 = syncPath; + setSgCliPath(syncPath); + return syncPath; + } + return null; + })(); + return initPromise; +} + +// src/process-output-timeout.ts +async function collectProcessOutputWithTimeout(process2, timeoutMs) { + const timeoutPromise = new Promise((_, reject) => { + const timeoutId = setTimeout(() => { + process2.kill(); + reject(new Error(`Search timeout after ${timeoutMs}ms`)); + }, timeoutMs); + process2.exited.then(() => clearTimeout(timeoutId)); + }); + const stdoutPromise = process2.stdout ? new Response(process2.stdout).text() : Promise.resolve(""); + const stderrPromise = process2.stderr ? new Response(process2.stderr).text() : Promise.resolve(""); + const stdout = await Promise.race([stdoutPromise, timeoutPromise]); + const stderr = await stderrPromise; + const exitCode = await process2.exited; + return { stdout, stderr, exitCode }; +} + +// src/runner.ts +async function runSg2(options) { + return runSg(options, { + resolveBinary: resolveBinaryPath, + spawnProcess + }); +} +async function resolveBinaryPath() { + const cliPath = getSgCliPath(); + if (cliPath && existsSync3(cliPath)) { + return cliPath; + } + const resolvedPath = await getAstGrepPath(); + if (!resolvedPath) { + const noEntryError = new Error("ENOENT: ast-grep binary not found"); + Reflect.set(noEntryError, "code", "ENOENT"); + throw noEntryError; + } + return resolvedPath; +} +async function spawnProcess(binary, args, options) { + const proc = spawn([binary, ...args], { + cwd: options?.cwd, + stdout: options?.stdout ?? "pipe", + stderr: options?.stderr ?? "pipe" + }); + return collectProcessOutputWithTimeout(proc, DEFAULT_TIMEOUT_MS); +} + +// src/tool-descriptions.ts +var AST_GREP_SEARCH_DESCRIPTION = [ + "Search code by AST structure (25 languages). This is NOT regex.", + "", + "Meta-variables (the only wildcards ast-grep understands):", + " $VAR - one AST node (an identifier, expression, statement, ...)", + " $$$ - zero or more nodes (argument lists, function bodies, ...)", + " $$$VAR - same, captured by name", + "Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.", + "", + "Regex syntax does NOT work - never pass these to pattern:", + ' "foo|bar" alternation → run separate calls, or switch to grep', + ' ".*", ".+" wildcards → use $$$ between AST fragments', + ' "\\w", "\\d" escapes → use $VAR to capture any identifier', + ' "[a-z]" class ranges → no AST equivalent', + "For text search, cross-language search, or regex features, use the grep tool instead.", + "", + "Examples by language:", + ` typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from '$MOD'"`, + ' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon', + ' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"', + ' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"', + "", + "On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep." +].join(` +`); +var AST_GREP_SEARCH_PATTERN_PARAM = "AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead."; +var AST_GREP_REPLACE_DESCRIPTION = [ + "Rewrite code by AST pattern (25 languages). Dry-run by default.", + "Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.", + "Meta-variables captured in pattern can be reused in rewrite to preserve matched content.", + 'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"', + "For text-only replacement or regex features, use a text editor instead." +].join(` +`); + +// src/workspace-paths.ts +import { existsSync as existsSync4, realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; +function normalizeWorkspaceDirectory(workspaceDirectory) { + return realpathSync(resolve(workspaceDirectory)); +} +function resolveWorkspacePaths(rawPaths, workspaceDirectory) { + const workspace = normalizeWorkspaceDirectory(workspaceDirectory); + const requestedPaths = rawPaths && rawPaths.length > 0 ? rawPaths : ["."]; + return requestedPaths.map((rawPath) => resolveWorkspacePath(rawPath, workspace)); +} +function resolveWorkspacePath(rawPath, workspaceDirectory) { + if (rawPath.length === 0) + throw new Error("paths entries must be non-empty strings"); + if (rawPath.startsWith("-")) + throw new Error(`paths entries must not start with '-': ${rawPath}`); + if (rawPath.includes("\x00")) + throw new Error("paths entries must not contain null bytes"); + if (isAbsolute(rawPath)) + return resolveAbsoluteWorkspacePath(rawPath, workspaceDirectory); + const absolutePath = resolve(workspaceDirectory, rawPath); + assertInsideWorkspace(absolutePath, workspaceDirectory, rawPath); + if (existsSync4(absolutePath)) { + const realPath = realpathSync(absolutePath); + assertInsideWorkspace(realPath, workspaceDirectory, rawPath); + } + const normalizedPath = relative(workspaceDirectory, absolutePath); + return normalizedPath === "" ? "." : normalizedPath; +} +function resolveAbsoluteWorkspacePath(rawPath, workspaceDirectory) { + let realPath; + try { + realPath = realpathSync(rawPath); + } catch { + throw new Error(`absolute path entry does not exist: ${rawPath}`); + } + assertInsideWorkspace(realPath, workspaceDirectory, rawPath); + const normalizedPath = relative(workspaceDirectory, realPath); + return normalizedPath === "" ? "." : normalizedPath; +} +function assertInsideWorkspace(candidatePath, workspaceDirectory, rawPath) { + const workspaceRelativePath = relative(workspaceDirectory, candidatePath); + if (workspaceRelativePath === "" || !workspaceRelativePath.startsWith("..") && !isAbsolute(workspaceRelativePath)) + return; + throw new Error(`paths entries must stay inside the workspace: ${rawPath}`); +} + +// src/mcp.ts +var SERVER_NAME = "ast_grep"; +var SERVER_VERSION = "0.1.0"; +var LANGUAGE_VALUES = CLI_LANGUAGES; +var DISABLED_TOOLS_ENV = "OMO_AST_GREP_DISABLED_TOOLS"; +var AST_GREP_MCP_TOOLS = [ + { + name: "search", + title: "AST grep search", + description: AST_GREP_SEARCH_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + pattern: { type: "string", description: AST_GREP_SEARCH_PATTERN_PARAM }, + lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" }, + paths: { type: "array", items: { type: "string" }, description: "Paths to search" }, + globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" }, + context: { type: "number", description: "Context lines around each match" } + }, + required: ["pattern", "lang"], + additionalProperties: false + } + }, + { + name: "replace", + title: "AST grep replace", + description: AST_GREP_REPLACE_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + pattern: { type: "string", description: "AST pattern to match" }, + rewrite: { type: "string", description: "Replacement pattern" }, + lang: { type: "string", enum: CLI_LANGUAGES, description: "Target language" }, + paths: { type: "array", items: { type: "string" }, description: "Paths to search" }, + globs: { type: "array", items: { type: "string" }, description: "Include/exclude globs" }, + dryRun: { type: "boolean", description: "Preview changes without applying. Defaults to true." } + }, + required: ["pattern", "rewrite", "lang"], + additionalProperties: false + } + } +]; +async function handleAstGrepMcpRequest(input, options = {}) { + if (!isRecord2(input)) + return errorResponse2(null, -32600, "Invalid Request"); + const id = jsonRpcId2(input.id); + if (input.method === "notifications/initialized") + return; + if (input.method === "ping") + return successResponse(id, {}); + if (input.method === "initialize") { + return successResponse(id, { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + protocolVersion: requestedProtocolVersion(input.params) + }); + } + if (input.method === "tools/list") + return successResponse(id, { tools: enabledTools(options) }); + if (input.method === "tools/call") + return handleToolCall(id, input.params, options); + return errorResponse2(id, -32601, `Method not found: ${String(input.method)}`); +} +async function runMcpStdioServer(input = process.stdin, output = process.stdout, options = {}, stdioOptions = {}) { + await runJsonRpcStdioServer(handleAstGrepMcpRequest, input, output, options, stdioOptions); +} +async function handleToolCall(id, params, options) { + if (!isRecord2(params) || typeof params.name !== "string") + return errorResponse2(id, -32602, "tools/call requires params.name"); + try { + const result = await executeAstGrepTool(params.name, params.arguments, options); + return successResponse(id, { content: result.content, isError: result.isError ?? false }); + } catch (error) { + return successResponse(id, { content: [{ type: "text", text: messageFromError2(error) }], isError: true }); + } +} +async function executeAstGrepTool(name, args, options) { + if (disabledToolNames(options).has(name)) + throw new Error(`ast-grep tool is disabled: ${name}`); + const runner = options.runSg ?? runSg2; + const workspaceDirectory = normalizeWorkspaceDirectory(options.workspaceDirectory ?? process.env.OMO_AST_GREP_WORKSPACE ?? process.cwd()); + if (name === "search") { + const input = parseSearchArgs(args, workspaceDirectory); + const result = await runner(input); + let output = formatSearchResult(result); + if (result.matches.length === 0 && !result.error) { + const hint = getPatternHint(input.pattern, input.lang); + if (hint) + output += ` + +${hint}`; + } + return { content: [{ type: "text", text: output }], isError: Boolean(result.error) }; + } + if (name === "replace") { + const input = parseReplaceArgs(args, workspaceDirectory); + const result = await runner(input.options); + return { content: [{ type: "text", text: formatReplaceResult(result, input.dryRun) }], isError: Boolean(result.error) }; + } + throw new Error(`Unknown ast-grep tool: ${name}`); +} +function parseSearchArgs(args, workspaceDirectory) { + const input = requireRecord(args); + return { + pattern: requireString(input, "pattern"), + lang: requireLanguage(input, "lang"), + cwd: workspaceDirectory, + paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory), + globs: optionalStringArray(input, "globs"), + context: optionalNumber(input, "context") + }; +} +function parseReplaceArgs(args, workspaceDirectory) { + const input = requireRecord(args); + const dryRun = optionalBoolean(input, "dryRun") ?? true; + return { + dryRun, + options: { + pattern: requireString(input, "pattern"), + rewrite: requireString(input, "rewrite"), + lang: requireLanguage(input, "lang"), + cwd: workspaceDirectory, + paths: resolveWorkspacePaths(optionalStringArray(input, "paths"), workspaceDirectory), + globs: optionalStringArray(input, "globs"), + updateAll: !dryRun + } + }; +} +function requireRecord(value) { + if (!isRecord2(value)) + throw new Error("Tool arguments must be an object"); + return value; +} +function requireString(input, key) { + const value = input[key]; + if (typeof value !== "string" || value.length === 0) + throw new Error(`${key} must be a non-empty string`); + return value; +} +function requireLanguage(input, key) { + const value = requireString(input, key); + if (!isCliLanguage(value)) + throw new Error(`${key} must be one of: ${LANGUAGE_VALUES.join(", ")}`); + return value; +} +function isCliLanguage(value) { + return LANGUAGE_VALUES.includes(value); +} +function optionalStringArray(input, key) { + const value = input[key]; + if (value === undefined) + return; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) + throw new Error(`${key} must be an array of strings`); + return value; +} +function enabledTools(options) { + const disabled = disabledToolNames(options); + return AST_GREP_MCP_TOOLS.filter((tool) => !disabled.has(tool.name)); +} +function disabledToolNames(options) { + const fromOptions = options.disabledTools ?? []; + const fromEnv = process.env[DISABLED_TOOLS_ENV]?.split(",") ?? []; + return new Set([...fromOptions, ...fromEnv].map((tool) => tool.trim()).filter(Boolean)); +} +function optionalNumber(input, key) { + const value = input[key]; + if (value === undefined) + return; + if (typeof value !== "number") + throw new Error(`${key} must be a number`); + return value; +} +function optionalBoolean(input, key) { + const value = input[key]; + if (value === undefined) + return; + if (typeof value !== "boolean") + throw new Error(`${key} must be a boolean`); + return value; +} +function successResponse(id, result) { + return { jsonrpc: "2.0", id, result }; +} +function errorResponse2(id, code, message, data) { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} +function requestedProtocolVersion(params) { + if (!isRecord2(params) || typeof params.protocolVersion !== "string") + return "2024-11-05"; + return params.protocolVersion; +} +function jsonRpcId2(value) { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} +function isRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function messageFromError2(error) { + return error instanceof Error ? error.message : String(error); +} + +// src/cli.ts +async function main() { + const [command = "mcp"] = argv.slice(2); + if (command === "mcp") { + await runMcpStdioServer(process.stdin, process.stdout, {}, { + log: writeMcpLifecycleLog, + onIdleTimeout: () => { + process.exit(0); + } + }); + return; + } + stderr.write(`Usage: ast-grep-mcp [mcp] +`); + process.exitCode = 2; +} +main().catch((error) => { + stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)} +`); + process.exitCode = 1; +}); diff --git a/plugins/omo/components/ast-grep-mcp/package.json b/plugins/omo/components/ast-grep-mcp/package.json new file mode 100644 index 0000000..efd6073 --- /dev/null +++ b/plugins/omo/components/ast-grep-mcp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@sisyphuslabs/omo-ast-grep-mcp-runtime", + "version": "0.1.0", + "description": "Bundled ast-grep MCP runtime for the aggregate OMO Codex plugin.", + "type": "module", + "private": true, + "license": "MIT", + "bin": { + "ast-grep-mcp": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20.0.0" + } +} diff --git a/plugins/omo/hooks/hooks.json b/plugins/omo/hooks/hooks.json index 37e1644..d961228 100644 --- a/plugins/omo/hooks/hooks.json +++ b/plugins/omo/hooks/hooks.json @@ -66,17 +66,6 @@ } ], "PreToolUse": [ - { - "matcher": "^Bash$", - "hooks": [ - { - "type": "command", - "command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook pre-tool-use", - "timeout": 5, - "statusMessage": "LazyCodex(4.8.0): Recommending Git Bash Mcp" - } - ] - }, { "matcher": "^create_goal$", "hooks": [ @@ -120,17 +109,6 @@ } ], "PostCompact": [ - { - "matcher": "manual|auto", - "hooks": [ - { - "type": "command", - "command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook post-compact", - "timeout": 5, - "statusMessage": "LazyCodex(4.8.0): Resetting Git Bash Mcp Reminder" - } - ] - }, { "matcher": "manual|auto", "hooks": [ diff --git a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs index c56b746..63be996 100644 --- a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs +++ b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs @@ -21,12 +21,6 @@ const runtimes = [ bundledRoot: join(bundledComponentsRoot, "ast-grep-mcp"), requiredOutputs: ["dist/cli.js"], }, - { - label: "git-bash-mcp", - sourceRoot: join(repoPackagesRoot, "git-bash-mcp"), - bundledRoot: join(bundledComponentsRoot, "git-bash-mcp"), - requiredOutputs: ["dist/cli.js"], - }, ]; for (const runtime of runtimes) { @@ -45,7 +39,7 @@ function buildRuntime(runtime) { } if (!existsSync(join(runtime.sourceRoot, "package.json"))) { - console.warn(`Skipping ${runtime.label}; no source package or bundled runtime found`); + assertRequiredOutputs(runtime.bundledRoot, runtime); return; } diff --git a/plugins/omo/test/aggregate-hooks.test.mjs b/plugins/omo/test/aggregate-hooks.test.mjs index dee67a3..10d6115 100644 --- a/plugins/omo/test/aggregate-hooks.test.mjs +++ b/plugins/omo/test/aggregate-hooks.test.mjs @@ -157,7 +157,7 @@ test("#given hook status messages #when inspected #then labels describe OMO resp assert.deepEqual(genericStatusMessages, []); }); -test("#given aggregate OMO plugin is enabled #when hooks are inspected #then shell guidance and ulw-loop guard are registered", async () => { +test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ulw-loop guard is registered without unavailable git_bash guidance", async () => { // given const hooks = await readJson("hooks/hooks.json"); const text = JSON.stringify(hooks); @@ -166,13 +166,11 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then she const preToolUseGroups = hooks.hooks.PreToolUse; // then - assert.match(text, /components\/git-bash\/dist\/cli\.js/); - assert.match(text, /Recommending Git Bash Mcp/); - assert.match(text, /hook post-compact/); - assert.match(text, /Resetting Git Bash Mcp Reminder/); + assert.doesNotMatch(text, /components\/git-bash\/dist\/cli\.js/); + assert.doesNotMatch(text, /Git Bash Mcp/); assert.match(text, /components\/ulw-loop\/dist\/cli\.js/); assert.match(text, /hook pre-tool-use/); - assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^Bash$", "^create_goal$"]); + assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]); }); test("#given aggregate SessionStart hooks #when inspected #then LazyCodex auto-update is registered", async () => { diff --git a/plugins/omo/test/aggregate-manifest.test.mjs b/plugins/omo/test/aggregate-manifest.test.mjs index d4737ea..fa91443 100644 --- a/plugins/omo/test/aggregate-manifest.test.mjs +++ b/plugins/omo/test/aggregate-manifest.test.mjs @@ -49,6 +49,7 @@ test("#given component directories #when scanned #then only intentional resource // then assert.deepEqual(componentNames, [ + "ast-grep-mcp", "comment-checker", "git-bash", "lsp", diff --git a/plugins/omo/test/aggregate-mcp.test.mjs b/plugins/omo/test/aggregate-mcp.test.mjs index 26c1fdd..8123f2c 100644 --- a/plugins/omo/test/aggregate-mcp.test.mjs +++ b/plugins/omo/test/aggregate-mcp.test.mjs @@ -8,7 +8,6 @@ import { exists, readJson, root } from "./aggregate-plugin-fixture.mjs"; const mcpPackageManifestPaths = [ "components/lsp-tools-mcp/package.json", "components/ast-grep-mcp/package.json", - "components/git-bash-mcp/package.json", ]; const mcpPackageManifestExists = await Promise.all(mcpPackageManifestPaths.map(exists)); @@ -22,19 +21,17 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack // when const lspServer = mcp.mcpServers.lsp; const astGrepServer = mcp.mcpServers.ast_grep; - const gitBashServer = mcp.mcpServers.git_bash; const codeMcpNames = Object.keys(mcp.mcpServers) - .filter((name) => name === "lsp" || name === "ast_grep" || name === "git_bash") + .filter((name) => name === "lsp" || name === "ast_grep") .sort(); const componentLocalMcpSources = lspSources.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts"); // then - assert.deepEqual(codeMcpNames, ["ast_grep", "git_bash", "lsp"]); + assert.deepEqual(codeMcpNames, ["ast_grep", "lsp"]); assert.equal(packageJson.workspaces.includes("components/lsp/packages/lsp-tools-mcp"), false); assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-mcp"), false); assert.deepEqual(packageJson.dependencies, { "@oh-my-opencode/shared-skills": "file:../../shared-skills" }); assert.match(bundledMcpBuildScript, /ast-grep-mcp/); - assert.match(bundledMcpBuildScript, /git-bash-mcp/); assert.doesNotMatch(packageJson.scripts.build, /--workspaces/); assert.equal(lspServer.command, "node"); assert.deepEqual(lspServer.args, ["./components/lsp-tools-mcp/dist/cli.js", "mcp"]); @@ -42,18 +39,34 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack assert.equal(astGrepServer.command, "node"); assert.deepEqual(astGrepServer.args, ["./components/ast-grep-mcp/dist/cli.js", "mcp"]); assert.equal(astGrepServer.cwd, "."); - assert.equal(gitBashServer.command, "node"); - assert.deepEqual(gitBashServer.args, ["./components/git-bash-mcp/dist/cli.js", "mcp"]); - assert.equal(gitBashServer.cwd, "."); assert.deepEqual(componentLocalMcpSources, []); }); +test("#given aggregate MCP config #when packaging is verified #then local MCP command targets exist", async () => { + // given + const mcp = await readJson(".mcp.json"); + + // when + const missingTargets = []; + for (const [name, server] of Object.entries(mcp.mcpServers)) { + if (typeof server.command !== "string") continue; + const [target] = server.args ?? []; + if (typeof target !== "string") continue; + if (!(await exists(join(server.cwd ?? ".", target)))) { + missingTargets.push(`${name}:${target}`); + } + } + + // then + assert.deepEqual(missingTargets, []); +}); + test( - "#given package-level MCP CLIs #when package metadata is inspected #then bin names use the omo prefix", + "#given package-level MCP CLIs #when package metadata is inspected #then bundled runtime bins point at local CLIs", { skip: mcpPackageManifestExists.some((exists) => !exists) }, async () => { // given - const [lspPackageJson, astGrepPackageJson, gitBashPackageJson] = await Promise.all( + const [lspPackageJson, astGrepPackageJson] = await Promise.all( mcpPackageManifestPaths.map((path) => readJson(path)), ); @@ -61,13 +74,9 @@ test( const binNames = [ ...Object.keys(lspPackageJson.bin ?? {}), ...Object.keys(astGrepPackageJson.bin ?? {}), - ...Object.keys(gitBashPackageJson.bin ?? {}), ].sort(); // then - assert.deepEqual(binNames, ["omo-ast-grep", "omo-git-bash", "omo-lsp"]); - for (const name of binNames) { - assert.match(name, /^omo-/); - } + assert.deepEqual(binNames, ["ast-grep-mcp", "lsp-tools-mcp"]); }, ); diff --git a/plugins/omo/test/mcp-research-servers.test.mjs b/plugins/omo/test/mcp-research-servers.test.mjs index 264a81a..96c9995 100644 --- a/plugins/omo/test/mcp-research-servers.test.mjs +++ b/plugins/omo/test/mcp-research-servers.test.mjs @@ -14,7 +14,7 @@ test("#given aggregate MCP config #when inspected #then registers research and s const serverNames = Object.keys(mcp.mcpServers).sort(); // then - assert.deepEqual(serverNames, ["ast_grep", "context7", "git_bash", "grep_app", "lsp"]); + assert.deepEqual(serverNames, ["ast_grep", "context7", "grep_app", "lsp"]); assert.equal(mcp.mcpServers.grep_app.url, "https://mcp.grep.app"); assert.equal(mcp.mcpServers.context7.url, "https://mcp.context7.com/mcp"); assert.deepEqual(mcp.mcpServers.ast_grep.args, ["./components/ast-grep-mcp/dist/cli.js", "mcp"]); From 23cee6f6a2741f6ecd3516df3cc044f4bbde13d8 Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 07:50:48 +0000 Subject: [PATCH 5/6] fix: keep rules hook dependency-free --- .../rules/dist/rules/glob-matcher.d.ts | 2 + .../rules/dist/rules/glob-matcher.js | 118 +++++++++++++ .../components/rules/dist/rules/matcher.js | 7 +- plugins/omo/components/rules/package.json | 5 +- .../rules/src/rules/glob-matcher.ts | 162 ++++++++++++++++++ .../omo/components/rules/src/rules/matcher.ts | 8 +- .../rules/test/package-smoke.test.ts | 4 +- plugins/omo/package-lock.json | 38 ++-- 8 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 plugins/omo/components/rules/dist/rules/glob-matcher.d.ts create mode 100644 plugins/omo/components/rules/dist/rules/glob-matcher.js create mode 100644 plugins/omo/components/rules/src/rules/glob-matcher.ts diff --git a/plugins/omo/components/rules/dist/rules/glob-matcher.d.ts b/plugins/omo/components/rules/dist/rules/glob-matcher.d.ts new file mode 100644 index 0000000..d16c62d --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/glob-matcher.d.ts @@ -0,0 +1,2 @@ +export declare function createGlobMatcher(pattern: string): (path: string) => boolean; +export declare function normalizeGlobPath(path: string): string; diff --git a/plugins/omo/components/rules/dist/rules/glob-matcher.js b/plugins/omo/components/rules/dist/rules/glob-matcher.js new file mode 100644 index 0000000..7c13dc8 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/glob-matcher.js @@ -0,0 +1,118 @@ +const REGEX_SPECIAL_CHARS = "^$+?.()|{}[]\\"; +export function createGlobMatcher(pattern) { + const regex = new RegExp(`^${parseGlobPattern(normalizeGlobPath(pattern))}$`); + return (path) => regex.test(normalizeGlobPath(path)); +} +export function normalizeGlobPath(path) { + return path.replaceAll("\\", "/"); +} +function parseGlobPattern(pattern) { + return parseGlobUntil(pattern, 0, "").source; +} +function parseGlobUntil(pattern, startIndex, terminators) { + let source = ""; + let index = startIndex; + while (index < pattern.length) { + const char = pattern.charAt(index); + if (terminators.includes(char)) { + return { source, index, terminator: char }; + } + if (char === "*") { + const parsed = parseStar(pattern, index); + source += parsed.source; + index = parsed.index; + continue; + } + if (char === "?") { + source += "[^/]"; + index += 1; + continue; + } + if (char === "[") { + const parsed = parseCharacterClass(pattern, index); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + if (char === "{") { + const parsed = parseDelimitedAlternatives(pattern, index + 1, "}", ","); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + if (char === "@" && pattern.charAt(index + 1) === "(") { + const parsed = parseDelimitedAlternatives(pattern, index + 2, ")", "|"); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + source += escapeRegexChar(char); + index += 1; + } + return { source, index }; +} +function parseStar(pattern, startIndex) { + if (pattern.charAt(startIndex + 1) !== "*") { + return { source: "[^/]*", index: startIndex + 1 }; + } + let index = startIndex + 2; + while (pattern.charAt(index) === "*") { + index += 1; + } + if (pattern.charAt(index) === "/") { + return { source: "(?:.*/)?", index: index + 1 }; + } + return { source: ".*", index }; +} +function parseCharacterClass(pattern, startIndex) { + let source = "["; + let index = startIndex + 1; + if (index >= pattern.length) { + return undefined; + } + if (pattern.charAt(index) === "!") { + source += "^"; + index += 1; + } + else if (pattern.charAt(index) === "^") { + source += "\\^"; + index += 1; + } + while (index < pattern.length) { + const char = pattern.charAt(index); + if (char === "]") { + return { source: `${source}]`, index }; + } + source += escapeCharacterClassChar(char); + index += 1; + } + return undefined; +} +function parseDelimitedAlternatives(pattern, startIndex, endChar, separator) { + const alternatives = []; + let index = startIndex; + while (index < pattern.length) { + const parsed = parseGlobUntil(pattern, index, `${separator}${endChar}`); + alternatives.push(parsed.source); + if (parsed.terminator === endChar) { + return { source: `(?:${alternatives.join("|")})`, index: parsed.index }; + } + if (parsed.terminator !== separator) { + return undefined; + } + index = parsed.index + 1; + } + return undefined; +} +function escapeRegexChar(char) { + return REGEX_SPECIAL_CHARS.includes(char) ? `\\${char}` : char; +} +function escapeCharacterClassChar(char) { + return char === "\\" || char === "]" ? `\\${char}` : char; +} diff --git a/plugins/omo/components/rules/dist/rules/matcher.js b/plugins/omo/components/rules/dist/rules/matcher.js index e9c4438..8b3b9e9 100644 --- a/plugins/omo/components/rules/dist/rules/matcher.js +++ b/plugins/omo/components/rules/dist/rules/matcher.js @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import picomatch from "picomatch"; +import { createGlobMatcher, normalizeGlobPath } from "./glob-matcher.js"; const compiledPatternSets = new Map(); export function matchRule(input) { if (input.isSingleFile) { @@ -45,7 +45,7 @@ function normalizePatternList(patterns) { return Array.isArray(patterns) ? patterns : [patterns]; } function normalizePath(path) { - return path.replaceAll("\\", "/"); + return normalizeGlobPath(path); } function normalizedPathBases(pathBases) { const normalizedBases = [normalizePath(pathBases.projectRelative)]; @@ -77,9 +77,6 @@ function compilePatternSet(patterns) { } return { positivePatterns, negativeMatchers }; } -function createGlobMatcher(pattern) { - return picomatch(normalizePath(pattern), { bash: true, dot: true }); -} function isExcluded(pathBase, negativeMatchers) { for (const isMatch of negativeMatchers) { if (isMatch(pathBase)) { diff --git a/plugins/omo/components/rules/package.json b/plugins/omo/components/rules/package.json index 422bc80..b083227 100644 --- a/plugins/omo/components/rules/package.json +++ b/plugins/omo/components/rules/package.json @@ -46,13 +46,10 @@ "lint:fix": "biome check --write .", "check": "tsc --noEmit && biome check . && npm run build" }, - "dependencies": { - "picomatch": "^4.0.3" - }, + "dependencies": {}, "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^25.7.0", - "@types/picomatch": "^4.0.0", "typescript": "^6.0.3", "vitest": "^4.1.5" }, diff --git a/plugins/omo/components/rules/src/rules/glob-matcher.ts b/plugins/omo/components/rules/src/rules/glob-matcher.ts new file mode 100644 index 0000000..a3f26e7 --- /dev/null +++ b/plugins/omo/components/rules/src/rules/glob-matcher.ts @@ -0,0 +1,162 @@ +const REGEX_SPECIAL_CHARS = "^$+?.()|{}[]\\"; + +type ParseResult = { + readonly source: string; + readonly index: number; + readonly terminator?: string; +}; + +type DelimitedParseResult = { + readonly source: string; + readonly index: number; +}; + +export function createGlobMatcher(pattern: string): (path: string) => boolean { + const regex = new RegExp(`^${parseGlobPattern(normalizeGlobPath(pattern))}$`); + return (path: string) => regex.test(normalizeGlobPath(path)); +} + +export function normalizeGlobPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function parseGlobPattern(pattern: string): string { + return parseGlobUntil(pattern, 0, "").source; +} + +function parseGlobUntil(pattern: string, startIndex: number, terminators: string): ParseResult { + let source = ""; + let index = startIndex; + + while (index < pattern.length) { + const char = pattern.charAt(index); + if (terminators.includes(char)) { + return { source, index, terminator: char }; + } + + if (char === "*") { + const parsed = parseStar(pattern, index); + source += parsed.source; + index = parsed.index; + continue; + } + + if (char === "?") { + source += "[^/]"; + index += 1; + continue; + } + + if (char === "[") { + const parsed = parseCharacterClass(pattern, index); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + + if (char === "{") { + const parsed = parseDelimitedAlternatives(pattern, index + 1, "}", ","); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + + if (char === "@" && pattern.charAt(index + 1) === "(") { + const parsed = parseDelimitedAlternatives(pattern, index + 2, ")", "|"); + if (parsed !== undefined) { + source += parsed.source; + index = parsed.index + 1; + continue; + } + } + + source += escapeRegexChar(char); + index += 1; + } + + return { source, index }; +} + +function parseStar(pattern: string, startIndex: number): DelimitedParseResult { + if (pattern.charAt(startIndex + 1) !== "*") { + return { source: "[^/]*", index: startIndex + 1 }; + } + + let index = startIndex + 2; + while (pattern.charAt(index) === "*") { + index += 1; + } + + if (pattern.charAt(index) === "/") { + return { source: "(?:.*/)?", index: index + 1 }; + } + + return { source: ".*", index }; +} + +function parseCharacterClass(pattern: string, startIndex: number): DelimitedParseResult | undefined { + let source = "["; + let index = startIndex + 1; + if (index >= pattern.length) { + return undefined; + } + + if (pattern.charAt(index) === "!") { + source += "^"; + index += 1; + } else if (pattern.charAt(index) === "^") { + source += "\\^"; + index += 1; + } + + while (index < pattern.length) { + const char = pattern.charAt(index); + if (char === "]") { + return { source: `${source}]`, index }; + } + + source += escapeCharacterClassChar(char); + index += 1; + } + + return undefined; +} + +function parseDelimitedAlternatives( + pattern: string, + startIndex: number, + endChar: string, + separator: string, +): DelimitedParseResult | undefined { + const alternatives: string[] = []; + let index = startIndex; + + while (index < pattern.length) { + const parsed = parseGlobUntil(pattern, index, `${separator}${endChar}`); + alternatives.push(parsed.source); + + if (parsed.terminator === endChar) { + return { source: `(?:${alternatives.join("|")})`, index: parsed.index }; + } + + if (parsed.terminator !== separator) { + return undefined; + } + + index = parsed.index + 1; + } + + return undefined; +} + +function escapeRegexChar(char: string): string { + return REGEX_SPECIAL_CHARS.includes(char) ? `\\${char}` : char; +} + +function escapeCharacterClassChar(char: string): string { + return char === "\\" || char === "]" ? `\\${char}` : char; +} diff --git a/plugins/omo/components/rules/src/rules/matcher.ts b/plugins/omo/components/rules/src/rules/matcher.ts index 227e427..6e7fedb 100644 --- a/plugins/omo/components/rules/src/rules/matcher.ts +++ b/plugins/omo/components/rules/src/rules/matcher.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import picomatch from "picomatch"; +import { createGlobMatcher, normalizeGlobPath } from "./glob-matcher.js"; import type { MatchReason, RuleFrontmatter } from "./types.js"; export interface MatcherInput { @@ -83,7 +83,7 @@ function normalizePatternList(patterns: string | string[] | undefined): string[] } function normalizePath(path: string): string { - return path.replaceAll("\\", "/"); + return normalizeGlobPath(path); } function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] { @@ -123,10 +123,6 @@ function compilePatternSet(patterns: ReadonlyArray): CompiledPatternSet return { positivePatterns, negativeMatchers }; } -function createGlobMatcher(pattern: string): (path: string) => boolean { - return picomatch(normalizePath(pattern), { bash: true, dot: true }); -} - function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean { for (const isMatch of negativeMatchers) { if (isMatch(pathBase)) { diff --git a/plugins/omo/components/rules/test/package-smoke.test.ts b/plugins/omo/components/rules/test/package-smoke.test.ts index afa67ee..1b89883 100644 --- a/plugins/omo/components/rules/test/package-smoke.test.ts +++ b/plugins/omo/components/rules/test/package-smoke.test.ts @@ -51,6 +51,7 @@ describe("plugin package metadata", () => { const pluginJson = readPluginJson(".codex-plugin/plugin.json"); const hooksJson = readHooksJson("hooks/hooks.json"); const cliSource = readFileSync("src/cli.ts", "utf8"); + const matcherDist = readFileSync("dist/rules/matcher.js", "utf8"); const bundledRules = readdirSync("bundled-rules").sort(); // when @@ -67,7 +68,8 @@ describe("plugin package metadata", () => { // then expect(packageJson.type).toBe("module"); expect(packageJson.packageManager).toBe("npm@11.12.1"); - expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" }); + expect(packageJson.dependencies ?? {}).toEqual({}); + expect(matcherDist).not.toContain("picomatch"); expect(packageJson.bin["omo-rules"]).toBe("./dist/cli.js"); expect(packageJson.files).toContain("bundled-rules"); expect(bundledRules).toContain("windows-git-bash.md"); diff --git a/plugins/omo/package-lock.json b/plugins/omo/package-lock.json index f4d6ab3..2ec0d43 100644 --- a/plugins/omo/package-lock.json +++ b/plugins/omo/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sisyphuslabs/omo-codex-plugin", - "version": "0.1.0", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sisyphuslabs/omo-codex-plugin", - "version": "0.1.0", + "version": "4.8.0", "workspaces": [ "components/comment-checker", "components/git-bash", @@ -80,20 +80,27 @@ "node": ">=20.0.0" } }, + "components/lsp-tools-mcp": { + "name": "@code-yeongyu/lsp-tools-mcp", + "version": "0.1.0", + "license": "MIT", + "bin": { + "lsp-tools-mcp": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "components/rules": { "name": "@code-yeongyu/codex-rules", "version": "0.1.0", "license": "MIT", - "dependencies": { - "picomatch": "^4.0.3" - }, "bin": { "omo-rules": "dist/cli.js" }, "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^25.7.0", - "@types/picomatch": "^4.0.0", "typescript": "^6.0.3", "vitest": "^4.1.5" }, @@ -821,13 +828,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -1369,6 +1369,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1748,17 +1749,6 @@ "engines": { "node": ">=8" } - }, - "components/lsp-tools-mcp": { - "name": "@code-yeongyu/lsp-tools-mcp", - "version": "0.1.0", - "license": "MIT", - "bin": { - "lsp-tools-mcp": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - } } } } From 12be80c58c3d28a6f5820268947e000178fb2a29 Mon Sep 17 00:00:00 2001 From: LazyCodex Date: Wed, 10 Jun 2026 07:53:50 +0000 Subject: [PATCH 6/6] fix: align rules dist cli mode --- plugins/omo/components/rules/dist/cli.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/omo/components/rules/dist/cli.js diff --git a/plugins/omo/components/rules/dist/cli.js b/plugins/omo/components/rules/dist/cli.js old mode 100644 new mode 100755