From 73fa3299bf30fd6da5abfc74a0ee7a36e0a07f0a Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 1 Sep 2026 16:43:46 -0400 Subject: [PATCH] feat: implement native hooks runtime --- .gitignore | 7 + README.md | 74 +- ...-runtime-and-trusted-policies-27AUG2026.md | 9 + docs/event-contract.md | 5 + docs/implementation-plan.md | 12 +- docs/independent-review-27AUG2026.md | 13 + docs/todos.md | 4 + .../rules/typescript/no-dynamic-import-ts.yml | 6 + .../typescript/no-dynamic-import-tsx.yml | 6 + .../rules/typescript/no-non-null-ts.yml | 6 + .../rules/typescript/no-non-null-tsx.yml | 6 + .../policies/harness-tools-verification.ts | 8 + .../policies/pi-native-hooks-verification.ts | 8 + .../policies/typescript-no-dynamic-import.ts | 7 + .../typescript-no-non-null-assertion.ts | 7 + examples/personal-config/sgconfig.yml | 2 + examples/personal-config/tsconfig.json | 11 + .../policies/harness-tools-verification.ts | 8 + .../policies/pi-native-hooks-verification.ts | 8 + .../policies/typescript-no-dynamic-import.ts | 7 + .../typescript-no-non-null-assertion.ts | 7 + extensions/pi-native-hooks/index.ts | 8 + package-lock.json | 2431 +++++++++++++++++ package.json | 30 + skills/native-hook-authoring/SKILL.md | 16 + .../references/contracts.md | 16 + .../references/testing.md | 16 + src/analysis.ts | 21 + src/analyzers/ast-grep.ts | 44 + src/diagnostics.ts | 21 + src/execution-wave.ts | 30 + src/initializer.ts | 21 + src/policy-loader.ts | 74 + src/policy.ts | 44 + src/project-context.ts | 21 + src/runtime.ts | 103 + src/state.ts | 14 + src/verification.ts | 67 + src/workspace.ts | 62 + tests/analysis.test.ts | 2 + tests/ast-grep.test.ts | 3 + tests/execution-wave.test.ts | 4 + .../rules/typescript/no-dynamic-import-ts.yml | 6 + .../typescript/no-dynamic-import-tsx.yml | 6 + .../rules/typescript/no-non-null-ts.yml | 6 + .../rules/typescript/no-non-null-tsx.yml | 6 + .../policies/harness-tools-verification.ts | 8 + .../policies/pi-native-hooks-verification.ts | 8 + .../policies/typescript-no-dynamic-import.ts | 7 + .../typescript-no-non-null-assertion.ts | 7 + tests/fixtures/personal-config/sgconfig.yml | 2 + tests/fixtures/personal-config/tsconfig.json | 11 + tests/package.test.ts | 2 + tests/policy-loader.test.ts | 20 + tests/runtime.test.ts | 17 + tests/state.test.ts | 2 + tests/verification.test.ts | 4 + tests/workspace.test.ts | 4 + tsconfig.json | 15 + 59 files changed, 3392 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md create mode 100644 docs/event-contract.md create mode 100644 docs/independent-review-27AUG2026.md create mode 100644 docs/todos.md create mode 100644 examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml create mode 100644 examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml create mode 100644 examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml create mode 100644 examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml create mode 100644 examples/personal-config/policies/harness-tools-verification.ts create mode 100644 examples/personal-config/policies/pi-native-hooks-verification.ts create mode 100644 examples/personal-config/policies/typescript-no-dynamic-import.ts create mode 100644 examples/personal-config/policies/typescript-no-non-null-assertion.ts create mode 100644 examples/personal-config/sgconfig.yml create mode 100644 examples/personal-config/tsconfig.json create mode 100644 examples/policies/harness-tools-verification.ts create mode 100644 examples/policies/pi-native-hooks-verification.ts create mode 100644 examples/policies/typescript-no-dynamic-import.ts create mode 100644 examples/policies/typescript-no-non-null-assertion.ts create mode 100644 extensions/pi-native-hooks/index.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 skills/native-hook-authoring/SKILL.md create mode 100644 skills/native-hook-authoring/references/contracts.md create mode 100644 skills/native-hook-authoring/references/testing.md create mode 100644 src/analysis.ts create mode 100644 src/analyzers/ast-grep.ts create mode 100644 src/diagnostics.ts create mode 100644 src/execution-wave.ts create mode 100644 src/initializer.ts create mode 100644 src/policy-loader.ts create mode 100644 src/policy.ts create mode 100644 src/project-context.ts create mode 100644 src/runtime.ts create mode 100644 src/state.ts create mode 100644 src/verification.ts create mode 100644 src/workspace.ts create mode 100644 tests/analysis.test.ts create mode 100644 tests/ast-grep.test.ts create mode 100644 tests/execution-wave.test.ts create mode 100644 tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml create mode 100644 tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml create mode 100644 tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml create mode 100644 tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml create mode 100644 tests/fixtures/personal-config/policies/harness-tools-verification.ts create mode 100644 tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts create mode 100644 tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts create mode 100644 tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts create mode 100644 tests/fixtures/personal-config/sgconfig.yml create mode 100644 tests/fixtures/personal-config/tsconfig.json create mode 100644 tests/package.test.ts create mode 100644 tests/policy-loader.test.ts create mode 100644 tests/runtime.test.ts create mode 100644 tests/state.test.ts create mode 100644 tests/verification.test.ts create mode 100644 tests/workspace.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5075a81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +coverage/ +dist/ +*.log +.DS_Store +.mesh +.pi \ No newline at end of file diff --git a/README.md b/README.md index 93c35b2..0403ff8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,75 @@ # pi-native-hooks -Native Pi hooks runtime for shared analyzer results, contextual diagnostics, and verification completion gates. +A native Pi extension that coordinates trusted personal policies after workspace mutations. It shares one ast-grep analysis snapshot across diagnostic policies and gates settlement until exact, current verification evidence exists or the agent records a reasoned waiver. -The repository is seeded with an approved implementation plan. Runtime implementation has not started; begin with [`docs/implementation-plan.md`](docs/implementation-plan.md). +## Requirements + +- Node.js 22.19 or newer +- Pi 0.84.4 or compatible +- ast-grep 0.45 or newer for diagnostics + +## Install + +Development installation is a separate, user-level mutation and is **not** performed by this repository's tests: + +```bash +pi install /srv/data/code/jb-pi/pi-native-hooks +``` + +Then use: + +```text +/hooks-init --dry-run +/hooks-init --apply +/hooks-doctor +/reload +``` + +`--apply` displays every destination and asks for interactive confirmation. Existing files are preserved; newly created files are rolled back after a partial failure. + +## Trust model + +Files under `~/.config/pi-native-hooks/policies/*.ts` are trusted executable code. Importing them runs with Pi's filesystem, process, network, credentials, and outer sandbox authority. Structural validation is **not a sandbox**. The loader accepts only sorted direct-child `.ts` files and requires the config root, policies, ast-grep configuration, and rules to be owned by the current OS user, non-symlinked, regular files/directories, and not group/other writable. + +Personal policies use Node's erasable TypeScript subset: type-only syntax, interfaces, and `satisfies` work; enums, namespaces, parameter properties, JSX, and tsconfig transforms do not. Policies reload only with `/reload` or session replacement. + +## Diagnostics + +The starter policies flag TypeScript/TSX non-null assertions and dynamic imports. Both use `ast-grep:personal`, so one mutation wave runs one scan and fans the immutable snapshot to both policies. A justified exception uses the exact rule ID: + +```ts +const plugin = await import(path); // ast-grep-ignore: typescript-no-dynamic-import-ts -- runtime-selected plugin +``` + +Analyzer failures are visible and never turn a successful mutation into a tool error. Unchanged findings are not repeatedly delivered. + +## Verification gate + +Starter project policies require these exact standalone commands from the matched Git project root: + +```text +npm test +npm run typecheck +``` + +Focused, combined, altered, wrong-directory, failed, aborted, concurrent, or stale commands do not count. A state-free session treats existing dirty files as its baseline; requirements arm only after an observed relevant mutation or restoration of armed branch state. At `agent_settled`, outstanding checks trigger another Pi turn. + +The agent may call `pi_hooks_waive_verification` with a non-empty reason. A waiver is auditable, revision-scoped, distinguishable from a pass, and expires after the next relevant mutation. + +Pi exposes foreground bash completion as `isError` rather than a numeric exit code. If a shell is externally signaled but Pi reports success, this extension cannot independently disprove completion. The settlement gate also cannot prevent a user or external client from closing Pi. + +## Commands + +- `/hooks-status` — policy digests and paths, trust/load failures, project identity, workspace revision, analyzer health, findings, verification revisions, evidence, waivers, and failures +- `/hooks-init --dry-run` — preview external config writes +- `/hooks-init --apply` — request authorization and initialize missing starter files +- `/hooks-doctor` — validate policy loading and ast-grep availability + +## Development + +```bash +npm run typecheck +npm test +``` + +Architecture and acceptance details are in [`docs/implementation-plan.md`](docs/implementation-plan.md). Event payload and project identity assumptions are pinned in [`docs/event-contract.md`](docs/event-contract.md). diff --git a/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md b/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md new file mode 100644 index 0000000..a2f4df5 --- /dev/null +++ b/docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md @@ -0,0 +1,9 @@ +# ADR 0001: standalone runtime and trusted policies + +**Status:** accepted, 27 August 2026 + +`pi-native-hooks` is one Pi package and extension. It loads only direct-child TypeScript policies from the current OS user's `~/.config/pi-native-hooks/policies`, after owner, mode, regular-file, and no-symlink checks. Policies are trusted executable code with Pi's authority; validation is not a sandbox. + +Analyzer execution is package-owned and coalesced by effective analyzer key. The MVP adapter is ast-grep. Verification is exact-command, revision-scoped evidence, persisted on the active session branch. Pending armed checks force an `agent_settled` follow-up; a reasoned agent waiver is visible and expires on the next relevant mutation. + +Node-native erasable TypeScript is required on Node >=22.19. Unsupported syntax fails that policy with an actionable load error; no implicit transpiler fallback is used. diff --git a/docs/event-contract.md b/docs/event-contract.md new file mode 100644 index 0000000..ca69e25 --- /dev/null +++ b/docs/event-contract.md @@ -0,0 +1,5 @@ +# Runtime event and identity contract + +The runtime targets Pi 0.84.4. `tool_execution_start` carries `toolCallId`, `toolName`, and `args`; all parallel sibling starts are emitted in source order before execution. A finalized execution either enters `tool_result` (with final `input`, `content`, `details`, `usage`, and `isError`) followed by `tool_execution_end`, or may enter only `tool_execution_end` when blocked/invalid. Handlers are idempotent by tool-call ID. `agent_settled` is the completion candidate used by the gate. + +Project identity is SHA-256 of the canonical git root, initial commit object (or `NO_HEAD`), and package name, NUL-delimited. The mutable HEAD and index belong to the workspace fingerprint, not identity. A different canonical root or initial commit is a different project. This avoids remote-URL instability while preventing evidence transfer to an unrelated checkout. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9831e3f..d3b99b2 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -2,7 +2,7 @@ ## Status -**Approved on 27 August 2026 for later execution.** This repository is seeded with the reviewed plan only. Runtime implementation has not started and is intentionally outside the repository-creation task. +**Approved on 27 August 2026 and implemented as the repository baseline.** Activation remains a separate explicit user-authorized step; the package is not installed and live personal configuration is not created by implementation or tests. ## Purpose @@ -18,7 +18,7 @@ The extension will also establish a small toolkit and authoring skill for adding - Implement a native TypeScript Pi extension with `pi.on(...)`, not `pi-yaml-hooks`, `pi-steering`, or `pi-workflow`. - Build one standalone repository and Pi package named `pi-native-hooks`, with one extension of the same name. -- Install the package from `/srv/data/code/pi-native-hooks` as a Pi local-path package during development; do not depend on harness-tools link management. +- Install the package from `/srv/data/code/jb-pi/pi-native-hooks` as a Pi local-path package during development; do not depend on harness-tools link management. - Load personal hook policies as trusted TypeScript modules from `~/.config/pi-native-hooks/`. - Use ast-grep rule packs for syntax diagnostics. - Execute an analyzer once per overlapping mutation wave and analyzer configuration, then fan an immutable normalized result out to every interested policy. Individual policy modules must not spawn AST, lint, or LSP processes through the runtime interface. @@ -85,11 +85,11 @@ The first release is successful when: ## Repository and runtime boundaries -- **Repository:** `/srv/data/code/pi-native-hooks` +- **Repository:** `/srv/data/code/jb-pi/pi-native-hooks` - **Package and extension:** `pi-native-hooks` - **Extension entrypoint:** `extensions/pi-native-hooks/index.ts` - **Personal policy root:** `~/.config/pi-native-hooks/` -- **Installation:** Pi local-path package during development; registry publication is deferred. +- **Installation:** Pi local-path package from `/srv/data/code/jb-pi/pi-native-hooks` during development; registry publication is deferred. - **Runtime code:** package-owned lifecycle, analyzer, state, delivery, validation, and UI modules. - **Personal code:** user-owned policy modules and adjacent ast-grep rule files. Personal policies are not committed to this repository. - **Repository fixtures:** checked-in example policies and test fixtures that mirror the personal module contract without containing the user's live policy set. @@ -634,7 +634,7 @@ After approval, implementation should add: - A throwaway ast-grep 0.45.1 fixture detected both initial TypeScript patterns in one `scan --json=stream` invocation, did not confuse logical negation with a non-null expression, and honored a rule-specific suppression with a trailing rationale. - The ast-grep fixture confirmed warning findings return exit code zero and JSON source ranges are zero-based, both now reflected in the adapter plan. - Node 26.7 successfully imported an erasable `.ts` policy containing interfaces, `satisfies`, and a type-only import to an unavailable runtime package. Phase 0 still must repeat the contract on the minimum supported Node 22.19 runtime. -- Planning created only this repository's README and documentation. No runtime extension, live personal policy, Pi package setting, or global installation was changed. +- Planning originally created only this repository's README and documentation. Implementation subsequently added the runtime, tests, examples, and authoring skill. No live personal policy, Pi package setting, or global installation is changed without the separate activation approval. Independent agent review is deferred because direct subagent launches are currently being repaired. It remains the first execution prerequisite when implementation starts from this repository. @@ -653,4 +653,4 @@ The user accepted these consequences on 27 August 2026: 9. Pi's settlement gate cannot prevent a user or external client from closing the session. 10. Local-path package installation and creation of `~/.config/pi-native-hooks` are separate external mutations requiring approval after tests and review. -User approval is recorded. Implementation has not started; independent review remains the first execution prerequisite. +User approval is recorded. An independent pre-implementation review was completed before runtime changes; its blockers were addressed by `docs/event-contract.md`, the explicit project identity schema, fail-fast native TypeScript loading, and canonical repository paths. diff --git a/docs/independent-review-27AUG2026.md b/docs/independent-review-27AUG2026.md new file mode 100644 index 0000000..c7a260e --- /dev/null +++ b/docs/independent-review-27AUG2026.md @@ -0,0 +1,13 @@ +# Independent implementation review — 27 August 2026 + +A separate Pi agent reviewed the implementation after the full test and typecheck run. + +## Findings and resolution + +- **Tool-result patch flow:** reviewer suspected a wave map was returned to Pi. Reinspection and `tests/runtime.test.ts` confirm `ExecutionWaveCoordinator.result()` selects `.get(toolCallId)` and the runtime returns that per-call patch. No defect remained. +- **typebox packaging:** retained as a peer dependency, as required by Pi's package documentation for bundled core packages; it is also a development dependency for standalone typechecking. +- **Initializer path mapping:** fixed. The shipped template contains a placeholder and `/hooks-init --apply` substitutes the installed package root rather than copying a machine-specific path. +- **Equivalent project-root paths:** normalized with `path.resolve` before exact cwd comparison. Subdirectories remain intentionally invalid. +- **Runtime integration coverage:** added a focused registration and per-call tool-result augmentation test. + +The reviewer also confirmed the unfiltered test and typecheck commands passed. No unresolved review blocker remains. Activation and creation of live personal configuration remain separate user-authorized steps. diff --git a/docs/todos.md b/docs/todos.md new file mode 100644 index 0000000..b12523f --- /dev/null +++ b/docs/todos.md @@ -0,0 +1,4 @@ +# Todos + +- Validate the native TypeScript loader contract on Node 22.19 in CI (development host currently uses Node 26). +- Consider a user-configurable read-only custom-tool allowlist after MVP evidence is collected. diff --git a/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml b/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml new file mode 100644 index 0000000..c5e05b5 --- /dev/null +++ b/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-ts +language: TypeScript +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml b/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml new file mode 100644 index 0000000..71fd87b --- /dev/null +++ b/examples/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-tsx +language: Tsx +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml b/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml new file mode 100644 index 0000000..13d92b4 --- /dev/null +++ b/examples/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-ts +language: TypeScript +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml b/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml new file mode 100644 index 0000000..a9326f8 --- /dev/null +++ b/examples/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-tsx +language: Tsx +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/examples/personal-config/policies/harness-tools-verification.ts b/examples/personal-config/policies/harness-tools-verification.ts new file mode 100644 index 0000000..6539c71 --- /dev/null +++ b/examples/personal-config/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/examples/personal-config/policies/pi-native-hooks-verification.ts b/examples/personal-config/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..8d86698 --- /dev/null +++ b/examples/personal-config/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "pi-native-hooks" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/examples/personal-config/policies/typescript-no-dynamic-import.ts b/examples/personal-config/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..ae0cd1e --- /dev/null +++ b/examples/personal-config/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/examples/personal-config/policies/typescript-no-non-null-assertion.ts b/examples/personal-config/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..0ac251a --- /dev/null +++ b/examples/personal-config/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/examples/personal-config/sgconfig.yml b/examples/personal-config/sgconfig.yml new file mode 100644 index 0000000..0adbca3 --- /dev/null +++ b/examples/personal-config/sgconfig.yml @@ -0,0 +1,2 @@ +ruleDirs: + - ast-grep/rules diff --git a/examples/personal-config/tsconfig.json b/examples/personal-config/tsconfig.json new file mode 100644 index 0000000..1979272 --- /dev/null +++ b/examples/personal-config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "paths": { "pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] } + }, + "include": ["policies/*.ts"] +} diff --git a/examples/policies/harness-tools-verification.ts b/examples/policies/harness-tools-verification.ts new file mode 100644 index 0000000..6539c71 --- /dev/null +++ b/examples/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/examples/policies/pi-native-hooks-verification.ts b/examples/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..8d86698 --- /dev/null +++ b/examples/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "pi-native-hooks" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/examples/policies/typescript-no-dynamic-import.ts b/examples/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..ae0cd1e --- /dev/null +++ b/examples/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/examples/policies/typescript-no-non-null-assertion.ts b/examples/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..0ac251a --- /dev/null +++ b/examples/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/extensions/pi-native-hooks/index.ts b/extensions/pi-native-hooks/index.ts new file mode 100644 index 0000000..130237f --- /dev/null +++ b/extensions/pi-native-hooks/index.ts @@ -0,0 +1,8 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerInitializationCommands } from "../../src/initializer.ts"; +import { NativeHooksRuntime } from "../../src/runtime.ts"; + +export default function piNativeHooks(pi: ExtensionAPI): void { + new NativeHooksRuntime(pi); + registerInitializationCommands(pi); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..36b6643 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2431 @@ +{ + "name": "pi-native-hooks", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-native-hooks", + "version": "0.1.0", + "devDependencies": { + "@earendil-works/pi-coding-agent": "^0.84.4", + "@types/node": "^24.0.0", + "tsx": "^4.20.0", + "typebox": "^1.0.48", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22.19" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.0.tgz", + "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.0.tgz", + "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.4.tgz", + "integrity": "sha512-HyUnjaOXj6oN/6SNcr8A1J/ElRQA50FtIE0XUTSKAQVqmdlb9qdojOyUQwF/jULE5+yOEtGuVgi/N1RnBiNG+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.84.4", + "@earendil-works/pi-telemetry": "^0.84.4", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.4.tgz", + "integrity": "sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.4", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-client": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.4.tgz", + "integrity": "sha512-q398WY/3ZQHTizk7IKxApzqFV0xt4yM9LkSkwyqeLK5Bj5RwRjOWxESt26z4LgNp4O+8hqhqFPf/8fj4H5rE4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.4.tgz", + "integrity": "sha512-jmOlrqUmvhh/siNWFRXjYLJzhKFIHNsAQaysRwzQPQFnPAaV/vhqHsLH/MBsIISA1Rjj7WTUFR3nJrpXoLx39w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.84.4", + "@earendil-works/pi-ai": "^0.84.4", + "@earendil-works/pi-client": "^0.84.4", + "@earendil-works/pi-protocol": "^0.84.4", + "@earendil-works/pi-tui": "^0.84.4", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-protocol": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.4.tgz", + "integrity": "sha512-acyE9ozxkMiWiz/xyWpU0O9vwnYv0hyG889Vniv6Sg9c9zfsX+8MePnDNphBacY2Fvm1rxdsGmiVDSZl9yuDFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-protocol/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.4.tgz", + "integrity": "sha512-8e2CuxM+ht+hedQXTZmi5JVl6/xDK9RpSDL2+MbITevKYQhMZ/z6lJOTFgox3HQyGxO8mOZEtYGVeQNaD4OzqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.4.tgz", + "integrity": "sha512-nPUnwDkLtupPXnZQYrCwPFcuTydCDqTY6ZbFqhsL4S4kVq0AT418kPa/6uXwtaCD+MjBNBltb7ScTYX65yeE1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.3.25", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.25.tgz", + "integrity": "sha512-glTozrjba2L9Mqqy9Y9joPmrXGTW7aINuBPl8tdZJJIUIXprRWT1EU8riXUHhPOXSk4K3pDKL6OV5CkpoOKcbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e026cbc --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "pi-native-hooks", + "version": "0.1.0", + "description": "Native Pi hooks runtime for shared diagnostics and verification gates", + "type": "module", + "engines": { "node": ">=22.19" }, + "keywords": ["pi-package"], + "exports": { + "./policy": { "types": "./src/policy.ts", "default": "./src/policy.ts" } + }, + "pi": { + "extensions": ["./extensions/pi-native-hooks/index.ts"], + "skills": ["./skills/native-hook-authoring"] + }, + "scripts": { + "test": "node --import tsx --test --experimental-test-coverage tests/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "^0.84.4", + "@types/node": "^24.0.0", + "typebox": "^1.0.48", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/skills/native-hook-authoring/SKILL.md b/skills/native-hook-authoring/SKILL.md new file mode 100644 index 0000000..047daf9 --- /dev/null +++ b/skills/native-hook-authoring/SKILL.md @@ -0,0 +1,16 @@ +--- +name: native-hook-authoring +description: Create, revise, validate, or troubleshoot trusted personal pi-native-hooks diagnostic and verification policies and ast-grep fixtures. Use when adding focused post-mutation policies without duplicating lifecycle, process execution, state, or delivery logic. +compatibility: pi-native-hooks on Node >=22.19 +--- + +# Native hook authoring + +1. Read [contracts](references/contracts.md). Import policy contracts with `import type` only. +2. Keep evaluators pure. Request analyzer keys and consume immutable snapshots; never spawn analyzers, linters, LSPs, or shell processes from a policy. +3. Add or revise a fixture under the package's `tests/fixtures/personal-config`, not the live personal root. +4. For syntax steering, create warning/info/hint ast-grep rules with language-specific IDs and a rationale-bearing `ast-grep-ignore` example. +5. Run the focused fixture, `npm run typecheck`, and `npm test` as described in [testing](references/testing.md). +6. Preview personal installation with `/hooks-init --dry-run`. Never write `~/.config/pi-native-hooks` without explicit interactive authorization. Existing files must not be overwritten. + +Personal modules are trusted arbitrary code with Pi's authority. Validation is not sandboxing. Use Node's erasable TypeScript subset only. diff --git a/skills/native-hook-authoring/references/contracts.md b/skills/native-hook-authoring/references/contracts.md new file mode 100644 index 0000000..8a25391 --- /dev/null +++ b/skills/native-hook-authoring/references/contracts.md @@ -0,0 +1,16 @@ +# Policy contracts + +The authoritative declarations are exported by `pi-native-hooks/policy` from `src/policy.ts`. Do not copy them into personal config. + +A diagnostic policy declares `kind`, a unique `id`, analyzer keys, `appliesTo`, and a pure `evaluate`. `evaluate` receives readonly normalized snapshots and a process-free policy context. Filter findings by exact rule ID and return bounded guidance plus the supported suppression form. + +A verification policy declares a project matcher and exact requirements. Each requirement owns an exact command, `cwd: "project-root"`, and a pure `affects(path)` predicate. Do not infer scripts or accept command variants. + +Use: + +```ts +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +export default { /* ... */ } satisfies DiagnosticPolicy; +``` + +No runtime import is needed. Enums, namespaces, parameter properties, JSX, and tsconfig transforms are unsupported in personal modules. diff --git a/skills/native-hook-authoring/references/testing.md b/skills/native-hook-authoring/references/testing.md new file mode 100644 index 0000000..e632204 --- /dev/null +++ b/skills/native-hook-authoring/references/testing.md @@ -0,0 +1,16 @@ +# Policy testing + +Work only in repository fixtures until activation is explicitly approved. + +1. Put policy fixtures in `tests/fixtures/personal-config/policies/`. +2. Put ast-grep rules below `tests/fixtures/personal-config/ast-grep/rules/` and keep severity below `error`. +3. Cover TS and TSX positives, nearby negatives, the exact named suppression with a `-- rationale`, Unicode ranges, and malformed analyzer output where relevant. +4. Assert multiple policies sharing one analyzer key cause one adapter invocation. +5. Run: + +```bash +npm run typecheck +npm test +``` + +Use `/hooks-doctor` only after authorized initialization. Never make a policy evaluator invoke `ast-grep`, a linter, LSP, shell, or Pi API directly. diff --git a/src/analysis.ts b/src/analysis.ts new file mode 100644 index 0000000..d401930 --- /dev/null +++ b/src/analysis.ts @@ -0,0 +1,21 @@ +import type { AnalysisSnapshot, AnalyzerFailure } from "./policy.ts"; +export interface AnalysisRequest { readonly analyzerKey: string; readonly paths: readonly string[] } +export interface AnalyzerAdapter { analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise } +export class AnalysisCoordinator { + constructor(private readonly adapters: ReadonlyMap) {} + async run(requests: readonly AnalysisRequest[], revision: number, signal?: AbortSignal): Promise> { + const grouped = new Map>(); + for (const request of requests) { const paths = grouped.get(request.analyzerKey) ?? new Set(); for (const path of request.paths) paths.add(path); grouped.set(request.analyzerKey, paths); } + const entries = await Promise.all([...grouped].sort(([a], [b]) => a.localeCompare(b)).map(async ([key, paths]) => { + const adapter = this.adapters.get(key); + const sorted = [...paths].sort(); + if (!adapter) return [key, failed(key, revision, sorted, { kind: "missing", message: `No analyzer registered for ${key}` })] as const; + try { return [key, deepFreeze(await adapter.analyze(sorted, revision, signal))] as const; } + catch (error) { return [key, failed(key, revision, sorted, { kind: "internal", message: error instanceof Error ? error.message : String(error) })] as const; } + })); + return readonlyMap(entries); + } +} +function failed(key: string, workspaceRevision: number, paths: readonly string[], failure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: 0, failure: Object.freeze(failure) }); } +export function deepFreeze(value: T): T { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const item of Object.values(value as Record)) deepFreeze(item); } return value; } +export function readonlyMap(entries: readonly (readonly [K, V])[]): ReadonlyMap { const map = new Map(entries); Object.freeze(map); return new Proxy(map, { get(target, prop) { if (prop === "set" || prop === "delete" || prop === "clear") return undefined; const value = Reflect.get(target, prop, target) as unknown; return typeof value === "function" ? (value as Function).bind(target) : value; } }); } diff --git a/src/analyzers/ast-grep.ts b/src/analyzers/ast-grep.ts new file mode 100644 index 0000000..acfa82a --- /dev/null +++ b/src/analyzers/ast-grep.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { AnalyzerAdapter } from "../analysis.ts"; +import type { AnalysisSnapshot, AnalyzerFailure, Finding, Severity } from "../policy.ts"; +import type { Exec } from "../project-context.ts"; + +const MAX_PATHS = 200; const MAX_FINDINGS = 200; const MAX_STDOUT = 2_000_000; +export class AstGrepAdapter implements AnalyzerAdapter { + private versionChecked?: Promise; + constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {} + prepare(signal?: AbortSignal): Promise { return this.versionChecked ??= this.checkVersion(signal); } + async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise { + const started = performance.now(); const bounded = paths.slice(0, MAX_PATHS); + const versionFailure = await this.prepare(signal); + if (versionFailure) return failure("ast-grep:personal", workspaceRevision, bounded, started, versionFailure); + const scanPaths = bounded.map((path) => isAbsolute(path) ? path : resolve(this.root, path)); + const args = ["scan", "--config", this.configPath, "--json=stream", "--color=never", "--max-results", String(MAX_FINDINGS), "--", ...scanPaths]; + const result = await this.exec(this.executable, args, { timeout: 20_000, ...(signal ? { signal } : {}) }); + if (result.killed) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "killed", message: "ast-grep was killed or timed out" }); + if (result.code !== 0) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "nonzero", message: `ast-grep exited ${result.code}: ${result.stderr.slice(0, 500)}` }); + if (Buffer.byteLength(result.stdout) > MAX_STDOUT) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: "ast-grep output exceeded 2MB" }); + try { + const findings = result.stdout.split("\n").filter(Boolean).slice(0, MAX_FINDINGS).map((line) => normalize(JSON.parse(line), this.root)); + return Object.freeze({ analyzerKey: "ast-grep:personal", workspaceRevision, paths: Object.freeze([...bounded]), findings: Object.freeze(findings), durationMs: Math.round(performance.now() - started) }); + } catch (error) { return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: error instanceof Error ? error.message : String(error) }); } + } + private async checkVersion(signal?: AbortSignal): Promise { + const result = await this.exec(this.executable, ["--version"], { timeout: 5000, ...(signal ? { signal } : {}) }); + if (result.code !== 0) return { kind: "missing", message: `ast-grep is unavailable: ${result.stderr || result.stdout}` }; + const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/); if (!match || Number(match[1]) < 0 || Number(match[2]) < 45) return { kind: "incompatible", message: `ast-grep >=0.45 is required; got ${result.stdout.trim()}` }; + return undefined; + } +} +function normalize(value: unknown, root: string): Finding { + if (!value || typeof value !== "object") throw new Error("finding is not an object"); const row = value as Record; + const range = row.range as Record; const start = range?.start as Record; const end = range?.end as Record; + const text = typeof row.text === "string" ? row.text : ""; const rawPath = requiredString(row.file, "file"); const severity = row.severity; + if (!start || !end || !Number.isInteger(start.line) || !Number.isInteger(start.column) || !Number.isInteger(end.line) || !Number.isInteger(end.column)) throw new Error("finding has invalid range"); + if (severity !== "hint" && severity !== "info" && severity !== "warning") throw new Error(`unsupported steering severity ${String(severity)}`); + const path = isAbsolute(rawPath) ? relative(root, rawPath) : relative(root, resolve(root, rawPath)); + return Object.freeze({ ruleId: requiredString(row.ruleId, "ruleId"), severity: severity as Severity, message: requiredString(row.message, "message"), ...(typeof row.note === "string" ? { note: row.note } : {}), path, range: Object.freeze({ start: Object.freeze({ line: start.line as number, column: start.column as number }), end: Object.freeze({ line: end.line as number, column: end.column as number }) }), matchedTextHash: createHash("sha256").update(text).digest("hex") }); +} +function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`finding has invalid ${name}`); return value; } +function failure(key: string, revision: number, paths: readonly string[], started: number, analyzerFailure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision: revision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: Math.round(performance.now() - started), failure: Object.freeze(analyzerFailure) }); } diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..15e9e54 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import type { Diagnostic } from "./policy.ts"; +export class DiagnosticDelivery { + private delivered = new Set(); + constructor(private readonly limit = 20) {} + select(diagnostics: readonly Diagnostic[]): readonly Diagnostic[] { + const current = new Set(diagnostics.map(fingerprint)); + const fresh = diagnostics.filter((item) => !this.delivered.has(fingerprint(item))).slice(0, this.limit); + this.delivered = current; + return fresh; + } + clear(): void { this.delivered.clear(); } +} +export function fingerprint(diagnostic: Diagnostic): string { return createHash("sha256").update([diagnostic.policyId, diagnostic.ruleId, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.matchedTextHash].join("\0")).digest("hex"); } +export function renderDiagnostics(diagnostics: readonly Diagnostic[], total = diagnostics.length): string { + if (!diagnostics.length) return ""; + const lines = ["\n\n[pi-native-hooks diagnostics]"]; + for (const item of diagnostics) { lines.push(`- ${item.path}:${item.line + 1}:${item.column + 1} [${item.ruleId}] ${item.message}`); lines.push(` ${item.guidance}${item.exception ? ` Exception: ${item.exception}` : ""}`); } + if (total > diagnostics.length) lines.push(`- … ${total - diagnostics.length} additional finding(s) omitted`); + return lines.join("\n"); +} diff --git a/src/execution-wave.ts b/src/execution-wave.ts new file mode 100644 index 0000000..18a06c9 --- /dev/null +++ b/src/execution-wave.ts @@ -0,0 +1,30 @@ +export interface ObservedToolResult { readonly toolCallId: string; readonly toolName: string; readonly input: Record; readonly content: readonly unknown[]; readonly details: unknown; readonly isError: boolean; readonly usage?: unknown } +export interface WaveCall { readonly id: string; readonly toolName: string; readonly args: unknown; readonly sourceIndex: number; readonly potentiallyMutating: boolean; terminal: boolean; result?: ObservedToolResult } +export interface CompletedWave { readonly id: string; readonly turnIndex: number; readonly calls: readonly WaveCall[]; readonly carrierId?: string; readonly potentiallyMutating: boolean } +export type WavePatches = ReadonlyMap; +interface ActiveWave { id: string; turnIndex: number; calls: WaveCall[]; finalized: boolean; timer?: NodeJS.Timeout; promise: Promise; resolve(value: WavePatches): void } +const READ_ONLY = new Set(["read", "grep", "find", "ls", "pi_hooks_waive_verification"]); +export class ExecutionWaveCoordinator { + private wave?: ActiveWave; private serial = 0; private turnIndex = 0; private stopped = false; + constructor(private readonly finalize: (wave: CompletedWave) => Promise, private readonly onUncertain: (message: string) => void, private readonly timeoutMs = 30_000) {} + turnStarted(turnIndex: number): void { this.turnIndex = turnIndex; } + start(toolCallId: string, toolName: string, args: unknown): { waveId: string; isNew: boolean; potentiallyMutating: boolean } | undefined { + if (this.stopped) return undefined; const isNew = !this.wave || this.wave.calls.every((call) => call.terminal); if (isNew) this.wave = this.createWave(); + const wave = this.wave; + if (!wave) return undefined; + if (wave.calls.some((call) => call.id === toolCallId)) return { waveId: wave.id, isNew: false, potentiallyMutating: !READ_ONLY.has(toolName) }; + const potentiallyMutating = !READ_ONLY.has(toolName); + wave.calls.push({ id: toolCallId, toolName, args, sourceIndex: wave.calls.length, potentiallyMutating, terminal: false }); + return { waveId: wave.id, isNew, potentiallyMutating }; + } + async result(result: ObservedToolResult): Promise<{ readonly content?: readonly unknown[]; readonly details?: unknown; readonly isError?: boolean; readonly usage?: unknown } | undefined> { + const wave = this.wave; if (!wave || this.stopped) return undefined; const call = wave.calls.find((item) => item.id === result.toolCallId); if (!call) { this.onUncertain(`tool_result without start: ${result.toolCallId}`); return undefined; } + if (!call.result) call.result = result; call.terminal = true; this.maybeFinalize(wave); return (await wave.promise).get(result.toolCallId); + } + end(toolCallId: string): void { const wave = this.wave; if (!wave || this.stopped) return; const call = wave.calls.find((item) => item.id === toolCallId); if (!call) { this.onUncertain(`tool_execution_end without start: ${toolCallId}`); return; } if (!call.terminal) call.terminal = true; this.maybeFinalize(wave); } + turnEnded(): void { if (this.wave && !this.wave.finalized && this.wave.calls.some((call) => !call.terminal)) this.releaseUncertain(this.wave, "turn ended with unterminated tool calls"); } + shutdown(): void { this.stopped = true; if (this.wave && !this.wave.finalized) this.releaseUncertain(this.wave, "session shutdown"); } + private createWave(): ActiveWave { let resolve!: (value: WavePatches) => void; const promise = new Promise((done) => { resolve = done; }); const wave: ActiveWave = { id: `wave-${++this.serial}`, turnIndex: this.turnIndex, calls: [], finalized: false, promise, resolve }; wave.timer = setTimeout(() => this.releaseUncertain(wave, "execution-wave barrier timeout"), this.timeoutMs); wave.timer.unref?.(); return wave; } + private maybeFinalize(wave: ActiveWave): void { if (wave.finalized || wave.calls.length === 0 || wave.calls.some((call) => !call.terminal)) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); const mutatingResults = wave.calls.filter((call) => call.potentiallyMutating && call.result); const participating = wave.calls.filter((call) => call.result); const carrier = (mutatingResults.at(-1) ?? participating.at(-1))?.id; const completed = Object.freeze({ id: wave.id, turnIndex: wave.turnIndex, calls: Object.freeze([...wave.calls]), ...(carrier ? { carrierId: carrier } : {}), potentiallyMutating: wave.calls.some((call) => call.potentiallyMutating) }); this.finalize(completed).then(wave.resolve, (error) => { this.onUncertain(`wave finalization failed: ${error instanceof Error ? error.message : String(error)}`); wave.resolve(new Map()); }); } + private releaseUncertain(wave: ActiveWave, message: string): void { if (wave.finalized) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); this.onUncertain(message); wave.resolve(new Map()); } +} diff --git a/src/initializer.ts b/src/initializer.ts new file mode 100644 index 0000000..673a4c1 --- /dev/null +++ b/src/initializer.ts @@ -0,0 +1,21 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPolicies, personalConfigRoot } from "./policy-loader.ts"; +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const templateRoot = join(packageRoot, "examples", "personal-config"); +async function filesUnder(root: string, prefix = ""): Promise { const result: string[] = []; for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { const path = join(prefix, entry.name); if (entry.isDirectory()) result.push(...await filesUnder(root, path)); else if (entry.isFile()) result.push(path); } return result.sort(); } +export function registerInitializationCommands(pi: ExtensionAPI): void { + pi.registerCommand("hooks-init", { description: "Preview or initialize trusted personal native-hook policies", handler: async (args, ctx) => { + const root = personalConfigRoot(); const files = await filesUnder(templateRoot); const plan = [`Personal config root: ${root}`, "Trusted executable files to create (existing files are preserved):", ...files.map((path) => `- ${join(root, path)}`)].join("\n"); + if (args.trim() === "--dry-run" || !args.trim()) { ctx.ui.notify(`${plan}\n\nRun /hooks-init --apply to request authorization.`, "info"); return; } + if (args.trim() !== "--apply") { ctx.ui.notify("Usage: /hooks-init --dry-run | --apply", "warning"); return; } + if (!ctx.hasUI || !await ctx.ui.confirm("Initialize pi-native-hooks?", `${plan}\n\nThese policies execute with Pi's authority. Continue?`)) { ctx.ui.notify("Initialization cancelled.", "info"); return; } + const created: string[] = []; + try { for (const path of files) { const target = join(root, path); try { await stat(target); continue; } catch {} await mkdir(dirname(target), { recursive: true, mode: 0o700 }); if (path === "tsconfig.json") { const template = await readFile(join(templateRoot, path), "utf8"); await writeFile(target, template.replaceAll("__PI_NATIVE_HOOKS_PACKAGE_ROOT__", packageRoot), { mode: 0o600 }); } else await copyFile(join(templateRoot, path), target); await chmod(target, 0o600); created.push(target); } ctx.ui.notify(`Created ${created.length} file(s); preserved ${files.length - created.length} existing file(s). Run /hooks-doctor then /reload.`, "info"); } + catch (error) { for (const path of created.reverse()) await rm(path, { force: true }).catch(() => {}); ctx.ui.notify(`Initialization failed and newly created files were rolled back: ${error instanceof Error ? error.message : String(error)}`, "error"); } + }}); + pi.registerCommand("hooks-doctor", { description: "Check native-hooks config trust and ast-grep availability", handler: async (_args, ctx) => { const registry = await loadPolicies(); const version = await pi.exec("ast-grep", ["--version"], { timeout: 5000 }); const lines = [registry.trustedCodeWarning, `Config: ${registry.root}`, `Policies: ${registry.policies.length}`, `Failures: ${registry.failures.length}`, ...registry.failures.map((failure) => `- ${failure.path}: ${failure.error}`), `ast-grep: ${version.code === 0 ? version.stdout.trim() : `unavailable (${version.stderr.trim()})`}`, `OS user: ${userInfo().username} uid ${userInfo().uid}`]; ctx.ui.notify(lines.join("\n"), registry.failures.length || version.code !== 0 ? "warning" : "info"); }}); +} diff --git a/src/policy-loader.ts b/src/policy-loader.ts new file mode 100644 index 0000000..6e7a900 --- /dev/null +++ b/src/policy-loader.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, lstat } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { validatePolicy, type PersonalPolicy } from "./policy.ts"; + +export interface LoadedPolicy { readonly path: string; readonly digest: string; readonly policy: PersonalPolicy } +export interface PolicyLoadFailure { readonly path: string; readonly error: string } +export interface PolicyRegistry { readonly root: string; readonly policies: readonly LoadedPolicy[]; readonly failures: readonly PolicyLoadFailure[]; readonly digest: string; readonly analyzerConfigSafe: boolean; readonly trustedCodeWarning: string } +export interface LoaderIO { + lstat(path: string): Promise<{ uid: number; mode: number; isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>; + readdir(path: string): Promise; + readFile(path: string): Promise; + importModule(url: string): Promise<{ default?: unknown }>; +} +const nativeIO: LoaderIO = { lstat, readdir: async (path) => readdir(path), readFile, importModule: (url) => import(url) }; +export const TRUST_WARNING = "Personal TypeScript policies are trusted executable code and run with Pi's filesystem, process, network, credential, and sandbox authority."; +export function personalConfigRoot(): string { return join(userInfo().homedir, ".config", "pi-native-hooks"); } + +async function safePath(io: LoaderIO, path: string, uid: number, kind: "file" | "directory"): Promise { + let stat; + try { stat = await io.lstat(path); } catch (error) { return error instanceof Error ? error.message : String(error); } + if (stat.isSymbolicLink()) return "symlinks are not allowed"; + if (stat.uid !== uid) return `must be owned by uid ${uid}`; + if ((stat.mode & 0o022) !== 0) return "must not be writable by group or others"; + if (kind === "file" ? !stat.isFile() : !stat.isDirectory()) return `must be a regular ${kind}`; + return undefined; +} +async function validateTree(io: LoaderIO, root: string, uid: number, failures: PolicyLoadFailure[]): Promise { + let safe = true; for (const name of await io.readdir(root)) { const path = join(root, name); let stat; try { stat = await io.lstat(path); } catch (error) { failures.push({ path, error: String(error) }); safe = false; continue; } + const kind = stat.isDirectory() ? "directory" : "file"; const error = await safePath(io, path, uid, kind); if (error) { failures.push({ path, error }); safe = false; continue; } if (kind === "directory" && !await validateTree(io, path, uid, failures)) safe = false; + } return safe; +} + +export async function loadPolicies(options: { root?: string; uid?: number; io?: LoaderIO } = {}): Promise { + const root = resolve(options.root ?? personalConfigRoot()); + const uid = options.uid ?? userInfo().uid; + const io = options.io ?? nativeIO; + const failures: PolicyLoadFailure[] = []; + const rootError = await safePath(io, root, uid, "directory"); + if (rootError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze(rootError.includes("ENOENT") ? [] : [{ path: root, error: rootError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); + let analyzerConfigSafe = true; + const configPath = join(root, "sgconfig.yml"); const configError = await safePath(io, configPath, uid, "file"); + if (configError) { analyzerConfigSafe = false; failures.push({ path: configPath, error: configError }); } + for (const directory of [join(root, "ast-grep"), join(root, "ast-grep", "rules")]) { const error = await safePath(io, directory, uid, "directory"); if (error) { analyzerConfigSafe = false; failures.push({ path: directory, error }); } } + if (analyzerConfigSafe && !await validateTree(io, join(root, "ast-grep", "rules"), uid, failures)) analyzerConfigSafe = false; + const policiesDir = join(root, "policies"); + const dirError = await safePath(io, policiesDir, uid, "directory"); + if (dirError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze([...failures, { path: policiesDir, error: dirError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); + const names = (await io.readdir(policiesDir)).filter((name) => name.endsWith(".ts") && !name.includes("/") && !name.includes("\\")).sort(); + const loaded: LoadedPolicy[] = []; + const ids = new Map(); + for (const name of names) { + const path = join(policiesDir, name); + const pathError = await safePath(io, path, uid, "file"); + if (pathError) { failures.push({ path, error: pathError }); continue; } + try { + const content = await io.readFile(path); + const digest = createHash("sha256").update(content).digest("hex"); + const module = await io.importModule(`${pathToFileURL(path).href}?native-hooks=${digest}`); + const result = validatePolicy(module.default); + if (!result.ok) { failures.push({ path, error: result.error }); continue; } + const duplicate = ids.get(result.policy.id); + if (duplicate) { failures.push({ path, error: `duplicate policy id ${result.policy.id}; first loaded from ${duplicate}` }); continue; } + ids.set(result.policy.id, path); + loaded.push(Object.freeze({ path, digest, policy: result.policy })); + } catch (error) { + failures.push({ path, error: `${error instanceof Error ? error.message : String(error)}. Policies must use Node's erasable TypeScript subset (no enums, namespaces, parameter properties, JSX, or tsconfig transforms).` }); + } + } + const digest = createHash("sha256").update(loaded.map((item) => `${item.policy.id}\0${item.digest}`).join("\0")).digest("hex"); + return Object.freeze({ root, policies: Object.freeze(loaded), failures: Object.freeze(failures), digest, analyzerConfigSafe, trustedCodeWarning: TRUST_WARNING }); +} diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..176f5f2 --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,44 @@ +export type Severity = "hint" | "info" | "warning"; + +export interface Finding { + readonly ruleId: string; + readonly severity: Severity; + readonly message: string; + readonly note?: string; + readonly path: string; + readonly range: Readonly<{ start: Readonly<{ line: number; column: number }>; end: Readonly<{ line: number; column: number }> }>; + readonly matchedTextHash: string; +} + +export interface AnalyzerFailure { readonly kind: "missing" | "incompatible" | "timeout" | "nonzero" | "malformed" | "killed" | "internal"; readonly message: string } +export interface AnalysisSnapshot { readonly analyzerKey: string; readonly workspaceRevision: number; readonly paths: readonly string[]; readonly findings: readonly Finding[]; readonly durationMs: number; readonly failure?: AnalyzerFailure } +export interface ProjectContext { readonly root: string; readonly packageName?: string; readonly identity: string; readonly git: boolean } +export interface PolicyContext { readonly project: ProjectContext; readonly changedPaths: readonly string[]; readonly workspaceRevision: number } +export interface Diagnostic { readonly policyId: string; readonly ruleId: string; readonly path: string; readonly line: number; readonly column: number; readonly message: string; readonly guidance: string; readonly exception?: string; readonly matchedTextHash: string } +export interface DiagnosticPolicy { readonly kind: "diagnostic"; readonly id: string; readonly analyzerKeys: readonly string[]; appliesTo(project: ProjectContext, paths: readonly string[]): boolean; evaluate(analysis: ReadonlyMap, context: PolicyContext): readonly Diagnostic[] } +export interface ProjectMatcher { matches(project: ProjectContext): boolean } +export interface VerificationRequirement { readonly id: string; readonly command: string; readonly cwd: "project-root"; affects(path: string): boolean } +export interface VerificationPolicy { readonly kind: "verification"; readonly id: string; readonly project: ProjectMatcher; readonly requirements: readonly VerificationRequirement[] } +export type PersonalPolicy = DiagnosticPolicy | VerificationPolicy; + +const isFn = (value: unknown): value is (...args: never[]) => unknown => typeof value === "function"; +export function validatePolicy(value: unknown): { ok: true; policy: PersonalPolicy } | { ok: false; error: string } { + if (!value || typeof value !== "object") return { ok: false, error: "default export must be an object" }; + const candidate = value as Record; + if (typeof candidate.id !== "string" || !candidate.id.trim()) return { ok: false, error: "policy id must be non-empty" }; + if (candidate.kind === "diagnostic") { + if (!Array.isArray(candidate.analyzerKeys) || candidate.analyzerKeys.some((key) => typeof key !== "string" || !key)) return { ok: false, error: "diagnostic analyzerKeys must be non-empty strings" }; + if (!isFn(candidate.appliesTo) || !isFn(candidate.evaluate)) return { ok: false, error: "diagnostic policy requires appliesTo and evaluate functions" }; + return { ok: true, policy: value as DiagnosticPolicy }; + } + if (candidate.kind === "verification") { + const project = candidate.project as Record | undefined; + if (!project || !isFn(project.matches) || !Array.isArray(candidate.requirements)) return { ok: false, error: "verification policy requires project.matches and requirements" }; + for (const requirement of candidate.requirements as unknown[]) { + const req = requirement as Record | undefined; + if (!req || typeof req.id !== "string" || !req.id || typeof req.command !== "string" || !req.command.trim() || req.cwd !== "project-root" || !isFn(req.affects)) return { ok: false, error: "invalid verification requirement" }; + } + return { ok: true, policy: value as VerificationPolicy }; + } + return { ok: false, error: "policy kind must be diagnostic or verification" }; +} diff --git a/src/project-context.ts b/src/project-context.ts new file mode 100644 index 0000000..7f0bd28 --- /dev/null +++ b/src/project-context.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import type { ProjectContext } from "./policy.ts"; + +export interface ExecResult { stdout: string; stderr: string; code: number | null; killed?: boolean } +export type Exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise; + +export async function discoverProject(cwd: string, exec: Exec, signal?: AbortSignal): Promise { + const top = await exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { timeout: 5000, ...(signal ? { signal } : {}) }); + const git = top.code === 0; + const root = await realpath(git ? top.stdout.trim() : cwd).catch(() => cwd); + let packageName: string | undefined; + try { const parsed = JSON.parse(await readFile(`${root}/package.json`, "utf8")) as { name?: unknown }; if (typeof parsed.name === "string") packageName = parsed.name; } catch {} + let origin = "NO_HEAD"; + if (git) { + const first = await exec("git", ["-C", root, "rev-list", "--max-parents=0", "HEAD"], { timeout: 5000, ...(signal ? { signal } : {}) }); + if (first.code === 0 && first.stdout.trim()) origin = first.stdout.trim().split("\n").sort()[0] ?? "NO_HEAD"; + } + const identity = createHash("sha256").update(`${root}\0${origin}\0${packageName ?? ""}`).digest("hex"); + return Object.freeze({ root, ...(packageName ? { packageName } : {}), identity, git }); +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..d090bab --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,103 @@ +import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { AnalysisCoordinator } from "./analysis.ts"; +import { AstGrepAdapter } from "./analyzers/ast-grep.ts"; +import { DiagnosticDelivery, renderDiagnostics } from "./diagnostics.ts"; +import { ExecutionWaveCoordinator, type CompletedWave, type ObservedToolResult, type WavePatches } from "./execution-wave.ts"; +import { loadPolicies, type PolicyRegistry } from "./policy-loader.ts"; +import type { Diagnostic, DiagnosticPolicy, ProjectContext, VerificationPolicy } from "./policy.ts"; +import { discoverProject } from "./project-context.ts"; +import { latestState, STATE_TYPE, type PersistedStateV1 } from "./state.ts"; +import { formatGate, VerificationTracker, type CheckAttempt } from "./verification.ts"; +import { explicitMutationPaths, WorkspaceInspector, type WorkspaceSnapshot } from "./workspace.ts"; + +const MESSAGE_TYPE = "pi-native-hooks-verification"; +export class NativeHooksRuntime { + private registry?: PolicyRegistry; private project?: ProjectContext; private tracker?: VerificationTracker; private workspace?: WorkspaceSnapshot; + private revision = 0; private diagnostics = new DiagnosticDelivery(); private lastDiagnosticCount = 0; private lastAnalyzer = "never"; private settlementRunning = false; private analysis?: AnalysisCoordinator; private astGrep?: AstGrepAdapter; + private readonly baselines = new Map(); private readonly attempts = new Map(); + private readonly inspector: WorkspaceInspector; private readonly waves: ExecutionWaveCoordinator; + constructor(private readonly pi: ExtensionAPI) { + const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); + this.inspector = new WorkspaceInspector(exec); + this.waves = new ExecutionWaveCoordinator((wave) => this.finalizeWave(wave), (message) => { this.tracker?.markUnknown(message); this.baselines.clear(); this.attempts.clear(); this.notify(message, "error"); }); + this.register(); + } + private register(): void { + this.pi.on("session_start", async (_event, ctx) => this.initialize(ctx)); + this.pi.on("session_tree", async (_event, ctx) => this.restoreBranch(ctx)); + this.pi.on("turn_start", (event) => this.waves.turnStarted(event.turnIndex)); + this.pi.on("tool_execution_start", async (event, ctx) => { const started = this.waves.start(event.toolCallId, event.toolName, event.args); if (started?.potentiallyMutating && !this.baselines.has(started.waveId)) this.baselines.set(started.waveId, await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal)); }); + this.pi.on("tool_call", (event, ctx) => { if (event.toolName === "bash" && this.tracker && typeof event.input.command === "string") this.attempts.set(event.toolCallId, this.tracker.beginCheck(event.toolCallId, event.input.command, ctx.cwd)); }); + this.pi.on("tool_result", async (event) => this.onToolResult(event)); + this.pi.on("tool_execution_end", (event) => this.waves.end(event.toolCallId)); + this.pi.on("turn_end", () => this.waves.turnEnded()); + this.pi.on("agent_settled", async (_event, ctx) => this.onSettled(ctx)); + this.pi.on("context", (event) => { const custom = event.messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "custom" && message.customType === MESSAGE_TYPE); const keep = this.tracker?.outstanding().length ? custom.at(-1)?.index : undefined; return { messages: event.messages.filter((message, index) => message.role !== "custom" || message.customType !== MESSAGE_TYPE || index === keep) }; }); + this.pi.on("session_shutdown", (_event, ctx) => { this.waves.shutdown(); ctx.ui.setStatus("pi-native-hooks", undefined); }); + const thisRuntime = this; + this.pi.registerTool({ name: "pi_hooks_waive_verification", label: "Waive verification", description: "Waive all currently outstanding pi-native-hooks checks for their current revisions. A non-empty reason is required and remains auditable.", promptSnippet: "Waive pending native-hook verification with an explicit reason", parameters: Type.Object({ reason: Type.String({ minLength: 1 }) }), async execute(toolCallId, params) { return thisRuntime.waive(toolCallId, params.reason); } }); + this.pi.registerCommand("hooks-status", { description: "Show pi-native-hooks policy, analyzer, and verification status", handler: async (_args, ctx) => ctx.ui.notify(this.statusText(), "info") }); + } + private async initialize(ctx: ExtensionContext): Promise { + const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); + this.registry = await loadPolicies(); this.project = await discoverProject(ctx.cwd, exec, ctx.signal); this.workspace = await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal); + this.astGrep = new AstGrepAdapter(exec, this.project.root, `${this.registry.root}/sgconfig.yml`); this.analysis = new AnalysisCoordinator(this.registry.analyzerConfigSafe ? new Map([["ast-grep:personal", this.astGrep]]) : new Map()); + if (this.registry.analyzerConfigSafe && this.diagnosticPolicies().length) { const failure = await this.astGrep.prepare(ctx.signal); if (failure) this.lastAnalyzer = `ast-grep:personal degraded (${failure.message})`; } + this.tracker = new VerificationTracker(this.project); this.tracker.configure(this.verificationPolicies()); await this.restoreBranch(ctx); this.updateStatus(ctx); + if (!this.registry.policies.length && !this.registry.failures.length) this.notify("No personal policies found. Run /hooks-init --dry-run for initialization guidance.", "info", ctx); + for (const failure of this.registry.failures) this.notify(`Policy load failed: ${failure.path}: ${failure.error}`, "error", ctx); + } + private async restoreBranch(ctx: ExtensionContext): Promise { + if (!this.registry || !this.project || !this.tracker || !this.workspace) return; const restored = latestState(ctx.sessionManager.getBranch()); + if (restored.malformed) this.notify(`Ignored ${restored.malformed} malformed native-hooks state entr${restored.malformed === 1 ? "y" : "ies"}.`, "warning", ctx); + const state = restored.state; if (!state) return; + if (state.projectIdentity !== this.project.identity) { this.tracker.restore(state.requirements.map((record) => ({ ...record, status: record.status === "inactive" ? "inactive" : "unknown" })), state.workspaceRevision); return; } + this.tracker.restore(state.requirements, state.workspaceRevision); this.revision = Math.max(this.revision, state.workspaceRevision); + if (state.policyDigest !== this.registry.digest) this.tracker.markUnknown("Policy registry changed since evidence was recorded"); + if (state.workspaceFingerprint !== this.workspace.fingerprint && this.tracker.records().some((record) => record.status !== "inactive")) this.tracker.markUnknown("Workspace changed outside the observed session"); + } + private async onToolResult(event: ToolResultEvent) { + const observed: ObservedToolResult = { toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, content: event.content, details: event.details, isError: event.isError, ...(event.usage ? { usage: event.usage } : {}) }; + const patch = await this.waves.result(observed); if (!patch) return; return patch as { content?: typeof event.content; details?: unknown; isError?: boolean; usage?: typeof event.usage }; + } + private async finalizeWave(wave: CompletedWave): Promise { + if (!this.project || !this.workspace || !this.tracker || !this.registry) return new Map(); const baseline = this.baselines.get(wave.id); this.baselines.delete(wave.id); let changedPaths: readonly string[] = []; let changed = false; let provable = true; + if (wave.potentiallyMutating) { + const after = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); + if (baseline) { const diff = this.inspector.diff(baseline, after); changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(baseline, after)])].sort()); changed = diff.changed; provable = diff.provable && (baseline.head === after.head || changedPaths.length > 0); } + else { changed = true; provable = false; } + const explicit = Object.freeze(wave.calls.flatMap((call) => call.result && !call.result.isError ? explicitMutationPaths(call.toolName, call.result.input) : [])); + if (!provable && explicit.length) { changed = true; changedPaths = explicit; } + else if (changed && !changedPaths.length) changedPaths = explicit; + if (changed) { this.revision++; this.tracker.observeMutation(changedPaths, this.revision, provable || changedPaths.length > 0); } + this.workspace = after; + } + const mutatingCount = wave.calls.filter((call) => call.potentiallyMutating).length; + for (const call of wave.calls) if (call.toolName === "bash" && call.result) { const attempts = this.attempts.get(call.id) ?? []; this.attempts.delete(call.id); const finalCommand = typeof call.result.input.command === "string" ? call.result.input.command : ""; const finalized = attempts.filter((attempt) => attempt.command === finalCommand); this.tracker.finishCheck(finalized, !call.result.isError, mutatingCount === 1 && !changed); } + let diagnostics: readonly Diagnostic[] = []; + if (changedPaths.length) diagnostics = await this.evaluateDiagnostics(changedPaths); + const fresh = this.diagnostics.select(diagnostics); this.lastDiagnosticCount = diagnostics.length; + this.persist(changed ? "workspace-revision" : "verification-evidence"); + if (!fresh.length || !wave.carrierId) return new Map(); const carrier = wave.calls.find((call) => call.id === wave.carrierId)?.result; if (!carrier) return new Map(); + const block = renderDiagnostics(fresh, diagnostics.length); const content = [...carrier.content, { type: "text", text: block }]; return new Map([[wave.carrierId, { content }]]); + } + private async evaluateDiagnostics(paths: readonly string[]): Promise { + if (!this.project || !this.registry || !this.analysis) return []; const policies = this.diagnosticPolicies().filter((policy) => { try { return policy.appliesTo(this.project!, paths); } catch (error) { this.notify(`Policy ${policy.id} appliesTo failed: ${String(error)}`, "error"); return false; } }); + const requests = policies.flatMap((policy) => policy.analyzerKeys.map((analyzerKey) => ({ analyzerKey, paths }))); + const snapshots = await this.analysis.run(requests, this.revision); this.lastAnalyzer = [...snapshots.values()].map((snapshot) => snapshot.failure ? `${snapshot.analyzerKey}: degraded (${snapshot.failure.message})` : `${snapshot.analyzerKey}: ${snapshot.durationMs}ms`).join(", ") || "not applicable"; + const diagnostics: Diagnostic[] = []; + for (const snapshot of snapshots.values()) if (snapshot.failure) diagnostics.push({ policyId: "pi-native-hooks-analyzer", ruleId: snapshot.analyzerKey, path: paths[0] ?? ".", line: 0, column: 0, message: `Analyzer degraded: ${snapshot.failure.message}`, guidance: "Diagnostics could not establish a clean scan; inspect /hooks-status and /hooks-doctor.", matchedTextHash: snapshot.failure.message }); + for (const policy of policies) { try { diagnostics.push(...policy.evaluate(snapshots, Object.freeze({ project: this.project, changedPaths: Object.freeze([...paths]), workspaceRevision: this.revision }))); } catch (error) { this.notify(`Policy ${policy.id} evaluation failed: ${String(error)}`, "error"); } } + return Object.freeze(diagnostics); + } + private async onSettled(ctx: ExtensionContext): Promise { if (this.settlementRunning || !this.tracker || !this.project || !this.workspace) return; this.settlementRunning = true; try { const live = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); if (live.fingerprint !== this.workspace.fingerprint) { const before = this.workspace; const diff = this.inspector.diff(before, live); const changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(before, live)])].sort()); this.revision++; this.tracker.observeMutation(changedPaths, this.revision, diff.provable && changedPaths.length > 0); this.workspace = live; this.persist("settlement-refresh"); } const outstanding = this.tracker.outstanding(); this.updateStatus(ctx); if (outstanding.length) this.pi.sendMessage({ customType: MESSAGE_TYPE, content: formatGate(outstanding), display: true, details: { revisions: outstanding.map((record) => [record.key, record.requiredRevision]) } }, { deliverAs: "followUp", triggerTurn: true }); } finally { this.settlementRunning = false; } } + private waive(toolCallId: string, reason: string) { if (!this.tracker) throw new Error("pi-native-hooks has not initialized"); const waived = this.tracker.waive(reason, toolCallId); this.persist("waiver"); return { content: [{ type: "text" as const, text: `Verification skipped (not passed) for: ${waived.map((record) => record.key).join(", ")}. Reason: ${reason.trim()}` }], details: { waived: waived.map((record) => ({ key: record.key, revision: record.requiredRevision })), reason: reason.trim() } }; } + private persist(reason: string): void { if (!this.registry || !this.project || !this.workspace || !this.tracker) return; const state: PersistedStateV1 = { version: 1, policyDigest: this.registry.digest, projectIdentity: this.project.identity, workspaceRevision: this.revision, workspaceFingerprint: this.workspace.fingerprint, requirements: this.tracker.records(), reason, timestamp: new Date().toISOString() }; this.pi.appendEntry(STATE_TYPE, state); } + private pathRelevant(path: string): boolean { for (const policy of this.verificationPolicies()) for (const requirement of policy.requirements) { try { if (requirement.affects(path)) return true; } catch { return true; } } return /\.(?:ts|tsx)$/.test(path); } + private diagnosticPolicies(): DiagnosticPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is DiagnosticPolicy => policy.kind === "diagnostic") ?? []; } + private verificationPolicies(): VerificationPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is VerificationPolicy => policy.kind === "verification") ?? []; } + private statusText(): string { const records = this.tracker?.records() ?? []; return [`Config: ${this.registry?.root ?? "not initialized"}`, this.registry?.trustedCodeWarning ?? "", `Policies: ${this.registry?.policies.map((item) => `${item.policy.id} ${item.digest.slice(0, 12)} (${item.path})`).join(", ") || "none"}`, `Load failures: ${this.registry?.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ") || "none"}`, `Project: ${this.project?.identity ?? "unknown"} (${this.project?.root ?? "unknown"})`, `Workspace revision: ${this.revision}`, `Analyzer: ${this.lastAnalyzer}`, `Diagnostics: ${this.lastDiagnosticCount}`, "Verification:", ...records.map((record) => `- ${record.key}: ${record.status} r${record.requiredRevision ?? "-"}${record.waiver ? ` waived: ${record.waiver.reason}` : ""}${record.lastFailure ? ` (${record.lastFailure})` : ""}`)].filter(Boolean).join("\n"); } + private updateStatus(ctx: ExtensionContext): void { const outstanding = this.tracker?.outstanding().length ?? 0; ctx.ui.setStatus("pi-native-hooks", `hooks ${this.lastDiagnosticCount}d ${outstanding ? `${outstanding} pending` : "verified"}`); } + private notify(message: string, level: "info" | "warning" | "error", ctx?: ExtensionContext): void { if (ctx) ctx.ui.notify(message, level); else console.error(`[pi-native-hooks] ${message}`); } +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..5a45e1a --- /dev/null +++ b/src/state.ts @@ -0,0 +1,14 @@ +import type { RequirementRecord } from "./verification.ts"; +export const STATE_TYPE = "pi-native-hooks-state-v1"; +export interface PersistedStateV1 { readonly version: 1; readonly policyDigest: string; readonly projectIdentity: string; readonly workspaceRevision: number; readonly workspaceFingerprint: string; readonly requirements: readonly RequirementRecord[]; readonly reason: string; readonly timestamp: string } +const statuses = new Set(["inactive", "not-run", "passed", "failed", "stale", "waived", "unknown"]); +export function isPersistedState(value: unknown): value is PersistedStateV1 { + if (!value || typeof value !== "object") return false; const state = value as Record; + if (state.version !== 1 || typeof state.policyDigest !== "string" || typeof state.projectIdentity !== "string" || !Number.isInteger(state.workspaceRevision) || typeof state.workspaceFingerprint !== "string" || !Array.isArray(state.requirements) || typeof state.reason !== "string" || typeof state.timestamp !== "string") return false; + return state.requirements.every((value) => { if (!value || typeof value !== "object") return false; const record = value as Record; return typeof record.key === "string" && typeof record.policyId === "string" && typeof record.requirementId === "string" && typeof record.command === "string" && statuses.has(record.status as string) && (record.requiredRevision === undefined || Number.isInteger(record.requiredRevision)); }); +} +export function latestState(entries: readonly unknown[]): { state?: PersistedStateV1; malformed: number } { + let state: PersistedStateV1 | undefined; let malformed = 0; + for (const entry of entries) { const row = entry as { type?: unknown; customType?: unknown; data?: unknown }; if (row?.type !== "custom" || row.customType !== STATE_TYPE) continue; if (isPersistedState(row.data)) state = row.data; else malformed++; } + return { ...(state ? { state } : {}), malformed }; +} diff --git a/src/verification.ts b/src/verification.ts new file mode 100644 index 0000000..9beb038 --- /dev/null +++ b/src/verification.ts @@ -0,0 +1,67 @@ +import { resolve } from "node:path"; +import type { ProjectContext, VerificationPolicy, VerificationRequirement } from "./policy.ts"; + +export type RequirementStatus = "inactive" | "not-run" | "passed" | "failed" | "stale" | "waived" | "unknown"; +export interface Evidence { readonly revision: number; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string } +export interface Waiver { readonly revision: number; readonly reason: string; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string } +export interface RequirementRecord { readonly key: string; readonly policyId: string; readonly requirementId: string; readonly command: string; readonly status: RequirementStatus; readonly requiredRevision?: number | undefined; readonly evidence?: Evidence | undefined; readonly waiver?: Waiver | undefined; readonly lastFailure?: string | undefined } +interface ActiveRequirement { policyId: string; requirement: VerificationRequirement; record: RequirementRecord } +export interface CheckAttempt { readonly key: string; readonly command: string; readonly startRevision: number; readonly toolCallId: string; readonly exact: boolean } + +export class VerificationTracker { + private readonly active = new Map(); + private orphaned: RequirementRecord[] = []; + constructor(readonly project: ProjectContext, private workspaceRevision = 0) {} + configure(policies: readonly VerificationPolicy[]): void { + const previous = new Map(this.active); + this.active.clear(); + for (const policy of policies) { + let matches = false; try { matches = policy.project.matches(this.project); } catch { continue; } if (!matches) continue; + for (const requirement of policy.requirements) { + const key = `${policy.id}:${requirement.id}`; const old = previous.get(key)?.record; previous.delete(key); + this.active.set(key, { policyId: policy.id, requirement, record: old && old.command === requirement.command.trim() ? old : { key, policyId: policy.id, requirementId: requirement.id, command: requirement.command.trim(), status: "inactive" } }); + } + } + this.orphaned = [...previous.values()].map((item) => ({ ...item.record, status: item.record.status === "inactive" ? "inactive" : "unknown", lastFailure: "Previously armed policy is missing or no longer matches" })); + } + restore(records: readonly RequirementRecord[], restoredRevision: number): void { + this.workspaceRevision = Math.max(this.workspaceRevision, restoredRevision); + const byKey = new Map(records.map((record) => [record.key, record])); + for (const active of this.active.values()) { const restored = byKey.get(active.record.key); if (restored && restored.command === active.record.command) active.record = restored; byKey.delete(active.record.key); } + this.orphaned = [...byKey.values()].filter((record) => record.status !== "inactive").map((record) => ({ ...record, status: "unknown", lastFailure: "Policy unavailable after restore" })); + } + observeMutation(paths: readonly string[], revision: number, provable = true): void { + this.workspaceRevision = Math.max(this.workspaceRevision, revision); + this.orphaned = this.orphaned.map((record) => record.status === "inactive" ? record : { ...record, status: "unknown", evidence: undefined, waiver: undefined, requiredRevision: revision, lastFailure: "Mutation observed while the original policy was unavailable" }); + for (const active of this.active.values()) { + let affected = false; try { affected = paths.some((path) => active.requirement.affects(path)); } catch { affected = true; } + if (!provable && active.record.status !== "inactive") { active.record = { ...active.record, status: "unknown", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: "Workspace mutation could not be proven" }; continue; } + if (!affected) continue; + const hadEvidence = active.record.evidence !== undefined; + active.record = { ...active.record, status: hadEvidence ? "stale" : "not-run", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: undefined }; + } + } + markUnknown(message: string): void { for (const active of this.active.values()) if (active.record.status !== "inactive") active.record = { ...active.record, status: "unknown", evidence: undefined, waiver: undefined, lastFailure: message }; } + beginCheck(toolCallId: string, command: string, cwd: string): readonly CheckAttempt[] { + return [...this.active.values()].filter((active) => active.record.status !== "inactive" && active.requirement.command.trim() === command.trim()).map((active) => ({ key: active.record.key, command, startRevision: this.workspaceRevision, toolCallId, exact: command === active.requirement.command.trim() && samePath(cwd, this.project.root) })); + } + finishCheck(attempts: readonly CheckAttempt[], success: boolean, stableWave: boolean): void { + for (const attempt of attempts) { const active = this.active.get(attempt.key); if (!active || active.record.requiredRevision === undefined) continue; + if (!attempt.exact || !stableWave || attempt.startRevision < active.record.requiredRevision || this.workspaceRevision !== attempt.startRevision) continue; + if (success) active.record = { ...active.record, status: "passed", evidence: { revision: active.record.requiredRevision, timestamp: new Date().toISOString(), toolCallId: attempt.toolCallId, projectIdentity: this.project.identity }, waiver: undefined, lastFailure: undefined }; + else active.record = { ...active.record, status: "failed", evidence: undefined, waiver: undefined, lastFailure: `Exact command failed: ${attempt.command}` }; + } + } + waive(reason: string, toolCallId: string): readonly RequirementRecord[] { + const trimmed = reason.trim(); if (!trimmed) throw new Error("Waiver reason must be non-empty"); const outstanding = this.outstanding(); if (!outstanding.length) throw new Error("No armed verification requirement is outstanding"); + const keys = new Set(outstanding.map((record) => record.key)); + for (const [key, active] of this.active) if (keys.has(key) && active.record.requiredRevision !== undefined) active.record = { ...active.record, status: "waived", waiver: { revision: active.record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity }, evidence: undefined }; + for (const record of this.orphaned) if (keys.has(record.key) && record.requiredRevision !== undefined) Object.assign(record, { status: "waived", waiver: { revision: record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity } }); + return outstanding; + } + outstanding(): RequirementRecord[] { return this.records().filter((record) => record.status !== "inactive" && record.status !== "passed" && record.status !== "waived"); } + records(): RequirementRecord[] { return [...this.active.values()].map((item) => ({ ...item.record } as RequirementRecord)).concat(this.orphaned.map((record) => ({ ...record }))); } + revision(): number { return this.workspaceRevision; } +} +function samePath(left: string, right: string): boolean { return resolve(left) === resolve(right); } +export function formatGate(records: readonly RequirementRecord[]): string { const commands = [...new Set(records.map((record) => record.command))]; return ["Required verification is pending or stale. Run each exact standalone command from the project root, or call pi_hooks_waive_verification with a non-empty reason:", ...commands.map((command) => `- ${command}`), "Outstanding: " + records.map((record) => `${record.key} (${record.status})`).join(", ")].join("\n"); } diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..d982ca4 --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, readlink, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import type { Exec } from "./project-context.ts"; + +export interface WorkspaceSnapshot { readonly kind: "git" | "fallback" | "unknown"; readonly root: string; readonly fingerprint: string; readonly entries: ReadonlyMap; readonly head?: string } +export interface WorkspaceDiff { readonly changedPaths: readonly string[]; readonly changed: boolean; readonly provable: boolean } +export class WorkspaceInspector { + constructor(private readonly exec: Exec) {} + async capture(cwd: string, relevant: (path: string) => boolean = () => true, signal?: AbortSignal): Promise { + const top = await this.exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], opts(signal)); + if (top.code !== 0) return Object.freeze({ kind: "unknown", root: cwd, fingerprint: "unknown", entries: new Map() }); + const root = await realpath(top.stdout.trim()).catch(() => top.stdout.trim()); + const [head, status] = await Promise.all([ + this.exec("git", ["-C", root, "rev-parse", "HEAD"], opts(signal)), + this.exec("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"], opts(signal)), + ]); + if (status.code !== 0 || Buffer.byteLength(status.stdout) > 2_000_000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const paths = parseStatus(status.stdout); if (paths.size > 2000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const dirtyPaths = [...paths.keys()]; const index = dirtyPaths.length ? await this.exec("git", ["-C", root, "ls-files", "--stage", "-z", "--", ...dirtyPaths], opts(signal)) : { stdout: "", stderr: "", code: 0 }; + if (index.code !== 0) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() }); + const indexEntries = parseIndex(index.stdout); + const entries = new Map(); + for (const [path, state] of paths) { + let content = "irrelevant"; + if (relevant(path)) content = await hashPath(join(root, path)); + entries.set(path, `${state}\0${indexEntries.get(path) ?? "untracked"}\0${content}`); + } + const headValue = head.code === 0 ? head.stdout.trim() : "NO_HEAD"; + const indexHash = createHash("sha256").update(index.stdout).digest("hex"); + const material = [headValue, indexHash, ...[...entries].sort(([a], [b]) => a.localeCompare(b)).flatMap(([path, value]) => [path, value])].join("\0"); + return Object.freeze({ kind: "git", root, fingerprint: createHash("sha256").update(material).digest("hex"), entries, head: headValue }); + } + diff(before: WorkspaceSnapshot, after: WorkspaceSnapshot): WorkspaceDiff { + if (before.kind !== "git" || after.kind !== "git" || before.root !== after.root) return Object.freeze({ changedPaths: Object.freeze([]), changed: before.fingerprint !== after.fingerprint, provable: false }); + const paths = new Set([...before.entries.keys(), ...after.entries.keys()]); + const changedPaths = [...paths].filter((path) => before.entries.get(path) !== after.entries.get(path)).sort(); + return Object.freeze({ changedPaths: Object.freeze([...new Set(changedPaths)]), changed: before.fingerprint !== after.fingerprint, provable: true }); + } + async headChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot, signal?: AbortSignal): Promise { + if (before.kind !== "git" || after.kind !== "git" || !before.head || !after.head || before.head === after.head) return []; + const result = await this.exec("git", ["-C", after.root, "diff", "--name-only", "-z", before.head, after.head, "--"], opts(signal)); + if (result.code !== 0 || Buffer.byteLength(result.stdout) > 2_000_000) return []; + return Object.freeze(result.stdout.split("\0").filter(Boolean).sort()); + } +} +function opts(signal?: AbortSignal) { return { timeout: 10_000, ...(signal ? { signal } : {}) }; } +function parseStatus(output: string): Map { + const tokens = output.split("\0"); const result = new Map(); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; if (!token || token.length < 4) continue; + const state = token.slice(0, 2); const path = token.slice(3); result.set(path, state); + if (state.includes("R") || state.includes("C")) { const oldPath = tokens[++index]; if (oldPath) result.set(oldPath, `${state}:source`); } + } + return result; +} +function parseIndex(output: string): Map { const result = new Map(); for (const token of output.split("\0")) { if (!token) continue; const tab = token.indexOf("\t"); if (tab > 0) result.set(token.slice(tab + 1), token.slice(0, tab)); } return result; } +async function hashPath(path: string): Promise { + try { const stat = await lstat(path); if (stat.isSymbolicLink()) return `symlink:${await readlink(path)}`; if (!stat.isFile()) return `mode:${stat.mode}:size:${stat.size}`; return createHash("sha256").update(await readFile(path)).digest("hex"); } + catch { return "missing"; } +} +export function explicitMutationPaths(toolName: string, input: Record): readonly string[] { if ((toolName === "write" || toolName === "edit") && typeof input.path === "string") return Object.freeze([input.path]); return Object.freeze([]); } diff --git a/tests/analysis.test.ts b/tests/analysis.test.ts new file mode 100644 index 0000000..3473cd9 --- /dev/null +++ b/tests/analysis.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { AnalysisCoordinator } from "../src/analysis.ts"; +test("coalesces paths and invokes one adapter per analyzer key", async () => { let calls = 0; let received: readonly string[] = []; const coordinator = new AnalysisCoordinator(new Map([["same", { async analyze(paths: readonly string[], revision: number) { calls++; received = paths; return { analyzerKey: "same", workspaceRevision: revision, paths, findings: [], durationMs: 1 }; } }]])); const result = await coordinator.run([{ analyzerKey: "same", paths: ["b.ts", "a.ts"] }, { analyzerKey: "same", paths: ["a.ts", "c.ts"] }], 4); assert.equal(calls, 1); assert.deepEqual(received, ["a.ts", "b.ts", "c.ts"]); assert.equal(result.get("same")?.workspaceRevision, 4); assert.equal((result as Map).set, undefined); }); diff --git a/tests/ast-grep.test.ts b/tests/ast-grep.test.ts new file mode 100644 index 0000000..a360377 --- /dev/null +++ b/tests/ast-grep.test.ts @@ -0,0 +1,3 @@ +import assert from "node:assert/strict"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { AstGrepAdapter } from "../src/analyzers/ast-grep.ts"; +const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const result = await run(command, args, { encoding: "utf8", maxBuffer: 3_000_000, ...options }); return { stdout: result.stdout, stderr: result.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number; killed?: boolean }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1, ...(row.killed === undefined ? {} : { killed: row.killed }) }; } }; +test("one real ast-grep scan detects both TypeScript rules and honors named suppression", async (t) => { try { await run("ast-grep", ["--version"]); } catch { t.skip("ast-grep unavailable"); return; } const root = await mkdtemp(join(tmpdir(), "native-hooks-scan-")); const path = join(root, "sample.ts"); await writeFile(path, `const value: string | undefined = "x";\nconsole.log(value!);\nconst loaded = import("./x.js"); // ast-grep-ignore: typescript-no-dynamic-import-ts -- plugin\n`); const adapter = new AstGrepAdapter(exec, root, join(process.cwd(), "examples/personal-config/sgconfig.yml")); const snapshot = await adapter.analyze([path], 1); assert.equal(snapshot.failure, undefined); assert.deepEqual(snapshot.findings.map((finding) => finding.ruleId), ["typescript-no-non-null-assertion-ts"]); }); diff --git a/tests/execution-wave.test.ts b/tests/execution-wave.test.ts new file mode 100644 index 0000000..4868d2a --- /dev/null +++ b/tests/execution-wave.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { ExecutionWaveCoordinator, type ObservedToolResult } from "../src/execution-wave.ts"; +const result = (id: string): ObservedToolResult => ({ toolCallId: id, toolName: "write", input: { path: `${id}.ts` }, content: [], details: {}, isError: false }); +test("groups parallel siblings, tolerates reverse completion, and picks source-order carrier", async () => { let finalized = 0; let carrier = ""; const waves = new ExecutionWaveCoordinator(async (wave) => { finalized++; carrier = wave.carrierId!; return new Map([[carrier, { content: [{ type: "text", text: "diagnostic" }] }]]); }, assert.fail); waves.start("first", "write", {}); waves.start("second", "write", {}); const second = waves.result(result("second")); const first = waves.result(result("first")); assert.deepEqual((await second)?.content, [{ type: "text", text: "diagnostic" }]); assert.equal((await first)?.content, undefined); assert.equal(finalized, 1); assert.equal(carrier, "second"); }); +test("immediate end without a tool result releases the wave", async () => { let calls = 0; const waves = new ExecutionWaveCoordinator(async () => { calls++; return new Map(); }, assert.fail); waves.start("blocked", "write", {}); waves.end("blocked"); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls, 1); }); diff --git a/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml new file mode 100644 index 0000000..c5e05b5 --- /dev/null +++ b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-ts +language: TypeScript +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml new file mode 100644 index 0000000..71fd87b --- /dev/null +++ b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-dynamic-import-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-dynamic-import-tsx +language: Tsx +severity: warning +message: Avoid dynamic imports unless runtime loading is intentional. +rule: + pattern: import($MODULE) diff --git a/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml new file mode 100644 index 0000000..13d92b4 --- /dev/null +++ b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-ts.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-ts +language: TypeScript +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml new file mode 100644 index 0000000..a9326f8 --- /dev/null +++ b/tests/fixtures/personal-config/ast-grep/rules/typescript/no-non-null-tsx.yml @@ -0,0 +1,6 @@ +id: typescript-no-non-null-assertion-tsx +language: Tsx +severity: warning +message: Avoid TypeScript non-null assertions. +rule: + kind: non_null_expression diff --git a/tests/fixtures/personal-config/policies/harness-tools-verification.ts b/tests/fixtures/personal-config/policies/harness-tools-verification.ts new file mode 100644 index 0000000..6539c71 --- /dev/null +++ b/tests/fixtures/personal-config/policies/harness-tools-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|scripts|services|skills|config)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "harness-tools-verification", project: { matches: (project) => project.packageName === "harness-tools" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts b/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts new file mode 100644 index 0000000..8d86698 --- /dev/null +++ b/tests/fixtures/personal-config/policies/pi-native-hooks-verification.ts @@ -0,0 +1,8 @@ +import type { VerificationPolicy } from "pi-native-hooks/policy"; +const manifests = new Set(["package.json", "package-lock.json", "tsconfig.json"]); +const behavior = (path: string) => /^(extensions|src|tests|examples|skills)\//.test(path) || manifests.has(path); +const types = (path: string) => /\.tsx?$/.test(path) || manifests.has(path); +export default { kind: "verification", id: "pi-native-hooks-verification", project: { matches: (project) => project.packageName === "pi-native-hooks" }, requirements: [ + { id: "tests", command: "npm test", cwd: "project-root", affects: behavior }, + { id: "typecheck", command: "npm run typecheck", cwd: "project-root", affects: types }, +] } satisfies VerificationPolicy; diff --git a/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts b/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts new file mode 100644 index 0000000..ae0cd1e --- /dev/null +++ b/tests/fixtures/personal-config/policies/typescript-no-dynamic-import.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-dynamic-import-ts", "typescript-no-dynamic-import-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-dynamic-import", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-dynamic-import", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts b/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts new file mode 100644 index 0000000..0ac251a --- /dev/null +++ b/tests/fixtures/personal-config/policies/typescript-no-non-null-assertion.ts @@ -0,0 +1,7 @@ +import type { DiagnosticPolicy } from "pi-native-hooks/policy"; +const ruleIds = new Set(["typescript-no-non-null-assertion-ts", "typescript-no-non-null-assertion-tsx"]); +export default { + kind: "diagnostic", id: "typescript-no-non-null-assertion", analyzerKeys: ["ast-grep:personal"], + appliesTo: (_project, paths) => paths.some((path) => /\.tsx?$/.test(path)), + evaluate: (analysis) => (analysis.get("ast-grep:personal")?.findings ?? []).filter((finding) => ruleIds.has(finding.ruleId)).map((finding) => ({ policyId: "typescript-no-non-null-assertion", ruleId: finding.ruleId, path: finding.path, line: finding.range.start.line, column: finding.range.start.column, message: finding.message, guidance: "Prefer a guard, refinement, assertion function, or corrected type.", exception: `Use ast-grep-ignore: ${finding.ruleId} -- when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })), +} satisfies DiagnosticPolicy; diff --git a/tests/fixtures/personal-config/sgconfig.yml b/tests/fixtures/personal-config/sgconfig.yml new file mode 100644 index 0000000..0adbca3 --- /dev/null +++ b/tests/fixtures/personal-config/sgconfig.yml @@ -0,0 +1,2 @@ +ruleDirs: + - ast-grep/rules diff --git a/tests/fixtures/personal-config/tsconfig.json b/tests/fixtures/personal-config/tsconfig.json new file mode 100644 index 0000000..1979272 --- /dev/null +++ b/tests/fixtures/personal-config/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "paths": { "pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] } + }, + "include": ["policies/*.ts"] +} diff --git a/tests/package.test.ts b/tests/package.test.ts new file mode 100644 index 0000000..bf4a098 --- /dev/null +++ b/tests/package.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { test } from "node:test"; +test("package exposes exactly one extension and the authoring skill", async () => { const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[]; skills: string[] }; engines: { node: string } }; assert.deepEqual(pkg.pi.extensions, ["./extensions/pi-native-hooks/index.ts"]); assert.deepEqual(pkg.pi.skills, ["./skills/native-hook-authoring"]); assert.equal(pkg.engines.node, ">=22.19"); }); diff --git a/tests/policy-loader.test.ts b/tests/policy-loader.test.ts new file mode 100644 index 0000000..184192c --- /dev/null +++ b/tests/policy-loader.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { chmod, cp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { mkdtemp } from "node:fs/promises"; +import { loadPolicies } from "../src/policy-loader.ts"; +const valid = `export default { kind: "diagnostic", id: "ok", analyzerKeys: [], appliesTo() { return true }, evaluate() { return [] } }`; +test("loads native erasable TypeScript deterministically and isolates failures", async () => { + const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 }); + await writeFile(join(policies, "b.ts"), `throw new Error("boom")`); await writeFile(join(policies, "a.ts"), `interface Marker { ok: true }; import type { PersonalPolicy } from "${join(process.cwd(), "src/policy.ts")}"; ${valid} satisfies PersonalPolicy`); + await chmod(join(policies, "a.ts"), 0o600); await chmod(join(policies, "b.ts"), 0o600); + const registry = await loadPolicies({ root }); assert.deepEqual(registry.policies.map((item) => item.policy.id), ["ok"]); assert.ok(registry.failures.some((failure) => /boom/.test(failure.error))); +}); +test("rejects duplicate IDs, unsafe mode, and symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "native-hooks-")); await chmod(root, 0o700); const policies = join(root, "policies"); await mkdir(policies, { mode: 0o700 }); + await writeFile(join(policies, "a.ts"), valid); await writeFile(join(policies, "b.ts"), valid.replace('id: "ok"', 'id: "ok"')); await writeFile(join(root, "target.ts"), valid); await symlink(join(root, "target.ts"), join(policies, "c.ts")); await writeFile(join(policies, "d.ts"), valid.replace('id: "ok"', 'id: "unsafe"')); + await Promise.all(["a.ts", "b.ts"].map((name) => chmod(join(policies, name), 0o600))); await chmod(join(policies, "d.ts"), 0o622); + const registry = await loadPolicies({ root, uid: userInfo().uid }); assert.equal(registry.policies.length, 1); assert.ok(registry.failures.some((failure) => /duplicate/.test(failure.error))); assert.ok(registry.failures.some((failure) => /symlink/.test(failure.error))); assert.ok(registry.failures.some((failure) => /group or others/.test(failure.error))); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts new file mode 100644 index 0000000..8156489 --- /dev/null +++ b/tests/runtime.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { NativeHooksRuntime } from "../src/runtime.ts"; + +test("runtime registers lifecycle hooks and returns the current call's result patch", async () => { + const handlers = new Map unknown>>(); const tools: string[] = []; const commands: string[] = []; + const pi = { + on(name: string, handler: (...args: unknown[]) => unknown) { const list = handlers.get(name) ?? []; list.push(handler); handlers.set(name, list); }, + registerTool(tool: { name: string }) { tools.push(tool.name); }, registerCommand(name: string) { commands.push(name); }, + } as unknown as ExtensionAPI; + const runtime = new NativeHooksRuntime(pi); + assert.deepEqual(tools, ["pi_hooks_waive_verification"]); assert.deepEqual(commands, ["hooks-status"]); assert.ok(handlers.has("agent_settled")); assert.ok(handlers.has("context")); + (runtime as unknown as { waves: { result(): Promise<{ content: Array<{ type: string; text: string }> }> } }).waves = { async result() { return { content: [{ type: "text", text: "augmented" }] }; } }; + const handler = handlers.get("tool_result")![0]!; const patch = await handler({ type: "tool_result", toolCallId: "call", toolName: "write", input: { path: "a.ts" }, content: [{ type: "text", text: "original" }], details: undefined, isError: false }, {}); + assert.deepEqual(patch, { content: [{ type: "text", text: "augmented" }] }); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts new file mode 100644 index 0000000..151d321 --- /dev/null +++ b/tests/state.test.ts @@ -0,0 +1,2 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import { latestState, STATE_TYPE } from "../src/state.ts"; +test("restores latest valid branch state and ignores malformed newer entries", () => { const state = { version: 1 as const, policyDigest: "p", projectIdentity: "i", workspaceRevision: 2, workspaceFingerprint: "f", requirements: [], reason: "test", timestamp: new Date().toISOString() }; const restored = latestState([{ type: "custom", customType: STATE_TYPE, data: state }, { type: "custom", customType: STATE_TYPE, data: { version: 1 } }]); assert.deepEqual(restored.state, state); assert.equal(restored.malformed, 1); }); diff --git a/tests/verification.test.ts b/tests/verification.test.ts new file mode 100644 index 0000000..7514090 --- /dev/null +++ b/tests/verification.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { test } from "node:test"; import type { VerificationPolicy } from "../src/policy.ts"; import { VerificationTracker } from "../src/verification.ts"; +const project = { root: "/repo", packageName: "x", identity: "identity", git: true } as const; const policy: VerificationPolicy = { kind: "verification", id: "verify", project: { matches: () => true }, requirements: [{ id: "tests", command: "npm test", cwd: "project-root", affects: (path) => path.startsWith("src/") }] }; +test("arms, accepts only exact stable evidence, stales, and waives by revision", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); assert.equal(tracker.records()[0]!.status, "inactive"); tracker.observeMutation(["src/a.ts"], 1); assert.equal(tracker.records()[0]!.status, "not-run"); const altered = tracker.beginCheck("bad", "npm test -- --focus", "/repo"); assert.equal(altered.length, 0); const attempt = tracker.beginCheck("good", "npm test", "/repo"); tracker.finishCheck(attempt, true, true); assert.equal(tracker.records()[0]!.status, "passed"); tracker.observeMutation(["src/b.ts"], 2); assert.equal(tracker.records()[0]!.status, "stale"); assert.throws(() => tracker.waive(" ", "w"), /non-empty/); tracker.waive("deferred by user", "w"); assert.equal(tracker.records()[0]!.status, "waived"); tracker.observeMutation(["src/c.ts"], 3); assert.equal(tracker.records()[0]!.status, "not-run"); }); +test("concurrent or wrong-cwd checks do not pass", () => { const tracker = new VerificationTracker(project); tracker.configure([policy]); tracker.observeMutation(["src/a.ts"], 1); tracker.finishCheck(tracker.beginCheck("wrong", "npm test", "/elsewhere"), true, true); assert.equal(tracker.records()[0]!.status, "not-run"); tracker.finishCheck(tracker.beginCheck("race", "npm test", "/repo"), true, false); assert.equal(tracker.records()[0]!.status, "not-run"); }); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..d6a8b29 --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,4 @@ +import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { test } from "node:test"; import { WorkspaceInspector } from "../src/workspace.ts"; +const run = promisify(execFile); const exec = async (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => { try { const value = await run(command, args, { encoding: "utf8", ...options }); return { stdout: value.stdout, stderr: value.stderr, code: 0 }; } catch (error) { const row = error as { stdout?: string; stderr?: string; code?: number }; return { stdout: row.stdout ?? "", stderr: row.stderr ?? "", code: row.code ?? 1 }; } }; +async function git(root: string, ...args: string[]) { const result = await exec("git", ["-C", root, ...args]); assert.equal(result.code, 0, result.stderr); } +test("git snapshots detect second edits, staging, untracked, rename, deletion, and symlinks", async () => { const root = await mkdtemp(join(tmpdir(), "native-hooks-git-")); await git(root, "init", "-q"); await git(root, "config", "user.email", "test@example.invalid"); await git(root, "config", "user.name", "Test"); await writeFile(join(root, "a.ts"), "export const a = 1;\n"); await git(root, "add", "a.ts"); await git(root, "commit", "-qm", "initial"); const inspector = new WorkspaceInspector(exec); const clean = await inspector.capture(root); await writeFile(join(root, "a.ts"), "export const a = 2;\n"); const dirty = await inspector.capture(root); assert.deepEqual(inspector.diff(clean, dirty).changedPaths, ["a.ts"]); await writeFile(join(root, "a.ts"), "export const a = 3;\n"); const second = await inspector.capture(root); assert.notEqual(second.fingerprint, dirty.fingerprint); assert.deepEqual(inspector.diff(dirty, second).changedPaths, ["a.ts"]); await git(root, "add", "a.ts"); const staged = await inspector.capture(root); assert.deepEqual(inspector.diff(second, staged).changedPaths, ["a.ts"]); await writeFile(join(root, "new.ts"), "new\n"); const untracked = await inspector.capture(root); assert.ok(inspector.diff(staged, untracked).changedPaths.includes("new.ts")); await symlink("a.ts", join(root, "link.ts")); const linked = await inspector.capture(root); assert.ok(inspector.diff(untracked, linked).changedPaths.includes("link.ts")); await rename(join(root, "a.ts"), join(root, "moved.ts")); const renamed = await inspector.capture(root); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("a.ts")); assert.ok(inspector.diff(linked, renamed).changedPaths.includes("moved.ts")); await rm(join(root, "moved.ts")); const deleted = await inspector.capture(root); assert.ok(inspector.diff(renamed, deleted).changedPaths.includes("moved.ts")); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b7a6b3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["extensions/**/*.ts", "src/**/*.ts", "tests/**/*.ts", "examples/**/*.ts"] +}