feat: package Pi extensions as Bun workspaces

This commit is contained in:
jb
2026-09-01 17:24:46 -04:00
parent 90f8090f61
commit 118b5bee06
214 changed files with 19566 additions and 5876 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
coverage/
dist/
*.log
.DS_Store
.mesh
.pi
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77
View File
@@ -0,0 +1,77 @@
# pi-native-hooks
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.
## Requirements
- Node.js 22.19 or newer
- Pi 0.84.4 or compatible
- ast-grep 0.45 or newer for diagnostics
## Install
Installation is a separate, user-level mutation and is **not** performed by this repository's tests:
```bash
pi install npm:@_jb/pi-native-hooks
```
For monorepo development, use the curated link policy or load `extensions/native-hooks/extensions/pi-native-hooks/index.ts` directly.
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
bun run test
bun 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
bun run typecheck
bun run 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).
@@ -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.
@@ -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.
@@ -0,0 +1,656 @@
# pi-native-hooks implementation plan
## Status
**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
Build one native Pi extension that runs trusted personal policies after relevant workspace mutations. The first policies will:
1. detect TypeScript non-null assertions and dynamic imports with AST-backed ast-grep rules, then append concise steering diagnostics to the mutation execution wave's tool results;
2. track exact required verification commands and prevent Pi from remaining settled while their evidence is missing or stale; and
3. provide an agent-callable, reasoned waiver when verification is intentionally skipped.
The extension will also establish a small toolkit and authoring skill for adding focused policies without duplicating Pi lifecycle, analyzer execution, state, or delivery logic.
## Decisions already made
- Implement a native TypeScript Pi extension with `pi.on(...)`, not `pi-yaml-hooks`, `pi-steering`, or `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/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.
- Require exact configured verification commands. A focused, filtered, combined, or otherwise altered command does not satisfy an exact requirement unless separately configured.
- Treat `agent_settled` as Pi's strongest available completion candidate. If armed verification is pending, inject a `followUp` custom message with `triggerTurn: true`.
- Arm verification only after this session observes a relevant mutation or restores an already-armed branch state. A new session treats pre-existing dirty files as its baseline rather than forcing checks during question-only work.
- Accept foreground verification when Pi reports `event.isError === false`. Document the narrow limitation that Pi's public bash result omits the exit code and can be ambiguous if the shell process itself ends through an external signal.
- Allow an agent-callable waiver tool. A waiver must contain a non-empty reason, is scoped to the requirement revisions and outstanding checks it clears, is persisted, and becomes stale after the next relevant mutation.
- Keep command sandboxing, destructive-command policy, permissions, and process isolation out of scope. nono profiles own those controls.
- Treat personal policy modules as executable in-process code with Pi's authority. Load only user-owned configuration from `~/.config/pi-native-hooks`; do not load repository-local policy code.
- Use Node's native erasable-TypeScript support on the Pi-supported Node `>=22.19` runtime. Avoid adding a TypeScript loader dependency unless the Phase 0 proof shows native loading is insufficient.
- Discover policies only at session startup or `/reload`; do not add a file watcher in the MVP.
## Outcomes
The first release is successful when:
- an overlapping parallel mutation wave produces at most one ast-grep process;
- both initial TypeScript policies receive the same normalized analysis snapshot;
- diagnostics identify rule, file, line, guidance, and the supported exception form;
- repeated unchanged findings do not repeatedly consume model context;
- writes made by `write`, `edit`, `bash`, and unknown custom tools are detected through workspace state rather than command-name guesses alone;
- each armed required check is valid only after it fully completes successfully at or after its current required revision;
- a foreground exact `bash` command can provide evidence through Pi's finalized success state;
- Pi is forced into a follow-up turn while required evidence is pending, until checks pass or the agent records a waiver;
- state survives session reload/resume without treating stale evidence as current;
- analyzer failures are visible but do not corrupt successful tool results;
- armed verification uncertainty fails closed to a pending gate;
- the hook-authoring skill can create a typed personal policy and focused fixture using the shared toolkit;
- policy load errors, ownership failures, and analyzer degradation are observable without crashing Pi; and
- the local-path Pi package exposes exactly one `pi-native-hooks` extension and the `native-hook-authoring` skill.
## Boundaries
### In scope
- Pi lifecycle coordination for overlapping tool execution waves, messages, settlement, and session state.
- Git-aware workspace snapshots and changed-path discovery.
- A shared analyzer planner and ast-grep adapter.
- Typed normalized findings and structurally validated diagnostic policy modules.
- Tool-result diagnostic augmentation, deduplication, status, and `/hooks-status` observability.
- Exact foreground `bash` verification evidence.
- Forced completion follow-ups and a reasoned waiver tool.
- Initial personal TypeScript diagnostic policies and verification policies for `pi-native-hooks` and `harness-tools`.
- A package-managed hook-authoring skill, policy type declarations, initializer/doctor commands, and local-path installation documentation.
### Deferred
- Implementations of lint and LSP adapters. The coordinator contract must support them, but the MVP implements only ast-grep.
- Async/background verification adapters. The removed `pi-background-tasks` package is not a runtime dependency or integration target.
- Repository-local or remotely downloaded executable policy modules. Only the fixed personal config root is loaded.
- Automatic inference of verification policy from package scripts.
- Semantic/type-aware TypeScript analysis beyond ast-grep's syntax tree.
- Automatic fixes.
- A universal task-completion protocol for clients other than Pi's `agent_settled` lifecycle.
- Accepting interactive `!`/`!!` commands as verification evidence. Out-of-band changes are detected by workspace snapshots, but required checks must run through an observable tool in the MVP.
### Explicit non-goals
- Shell command filtering or rewriting.
- Destructive-command confirmation.
- Filesystem, network, credential, or process sandboxing.
- Replacing project linters, typecheckers, test runners, or LSP servers.
## Repository and runtime boundaries
- **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 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.
The repository may ship an initializer that creates the personal directory, a policy-oriented `tsconfig.json`, and starter modules, but it must perform a dry run and request explicit authorization before writing outside the repository. Package install and personal policy initialization are separate operations.
## Trusted TypeScript policy model
Personal policy modules are arbitrary executable code. Importing one can perform side effects before its export is validated, so schema validation is not a sandbox. Documentation and `/hooks-status` must state that these files run with the same filesystem, process, network, and credential authority as Pi; nono remains responsible for the outer sandbox.
The one-run analyzer guarantee applies to toolkit-managed analyzer keys. The runtime withholds process authority from `PolicyContext`, and the authoring skill requires pure evaluators, but a trusted module can still import `node:child_process` and bypass the toolkit. Hard prevention would require declarative or isolated policies and conflicts with the chosen trusted-TypeScript model.
The loader must:
1. resolve the fixed `<current user's home>/.config/pi-native-hooks` root from the OS user record, without accepting environment or repository overrides;
2. require the root, `sgconfig.yml`, policy files, and rule files to be user-owned regular files/directories, not symlinks, and not writable by group or others;
3. discover only sorted direct-child `.ts` files from the documented `policies/` directory;
4. load each file through native dynamic import with an mtime/content cache token on Node `>=22.19`;
5. document the erasable-TypeScript subset: type-only syntax is supported, while enums, namespaces, parameter properties, JSX, and tsconfig transforms are not;
6. validate the default export structurally after import and reject duplicate IDs;
7. isolate a throwing/invalid module so other policies still load, while surfacing the exact file and error; and
8. reload policies only when Pi recreates the extension on `/reload` or session replacement.
The package exposes `@_jb/pi-native-hooks/policy` type declarations. An authorized initializer writes a personal `tsconfig.json` with a path mapping to the installed package so policies can use `import type` plus `satisfies` without a runtime package import. Runtime validation remains authoritative because type checking can be skipped or stale.
Suggested personal layout:
```text
~/.config/pi-native-hooks/
├── tsconfig.json
├── sgconfig.yml
├── policies/
│ ├── typescript-no-non-null-assertion.ts
│ ├── typescript-no-dynamic-import.ts
│ ├── pi-native-hooks-verification.ts
│ └── harness-tools-verification.ts
└── ast-grep/
└── rules/
└── typescript/*.yml
```
## Architecture
```text
~/.config/pi-native-hooks/policies/*.ts
|
v
+-----------------------+
| TrustedPolicyLoader | ownership checks + structural validation
+-----------+-----------+
| immutable policy registry
v
Pi lifecycle events
|
v
+----------------------------+
| ExecutionWaveCoordinator | records source order and terminal events,
| | compares pre/post workspace snapshots
+-------------+--------------+
| MutationWave
v
+-----------------------+ one invocation per analyzer key
| AnalysisCoordinator |----------------------------------+
+-----------+-----------+ |
| immutable AnalysisSnapshot v
| +--------------------+
+-------------------------------> | ast-grep adapter |
| +--------------------+
v
+-----------------------+
| Diagnostic policies | evaluation receives data, not process authority
+-----------+-----------+
| Diagnostics
v
+-----------------------+ +------------------------------+
| Result delivery | | Verification tracker |
| dedupe + augmentation | | revisions/evidence/waivers |
+-----------------------+ +--------------+---------------+
|
v
agent_settled follow-up gate
```
### Contract shapes
The implementation should converge on contracts equivalent to these, without treating these names as immutable API:
```ts
interface MutationWave {
readonly id: string;
readonly turnIndex: number;
readonly orderedToolCallIds: readonly string[];
readonly changedPaths: readonly string[];
readonly beforeFingerprint: string;
readonly afterFingerprint: string;
readonly workspaceRevision: number;
readonly toolResults: ReadonlyMap<string, ObservedToolResult>;
}
interface AnalysisRequest {
readonly analyzerKey: string;
readonly paths: readonly string[];
}
interface AnalysisSnapshot {
readonly analyzerKey: string;
readonly workspaceRevision: number;
readonly paths: readonly string[];
readonly findings: readonly Finding[];
readonly durationMs: number;
readonly failure?: AnalyzerFailure;
}
interface DiagnosticPolicy {
readonly kind: "diagnostic";
readonly id: string;
readonly analyzerKeys: readonly string[];
appliesTo(project: ProjectContext, paths: readonly string[]): boolean;
evaluate(
analysis: ReadonlyMap<string, AnalysisSnapshot>,
context: PolicyContext,
): readonly Diagnostic[];
}
interface VerificationPolicy {
readonly kind: "verification";
readonly id: string;
readonly project: ProjectMatcher;
readonly requirements: readonly VerificationRequirement[];
}
interface VerificationRequirement {
readonly id: string;
readonly command: string;
readonly cwd: "project-root";
affects(path: string): boolean;
}
type PersonalPolicy = DiagnosticPolicy | VerificationPolicy;
```
Freeze arrays/maps or expose readonly copies before calling policy modules. Do not expose the process runner, Pi API, session context, or mutable tracker through `PolicyContext`; this makes shared analyzer execution enforceable by construction. This limits what the runtime hands to a policy, but it does not sandbox top-level module code or imports.
## Tool execution wave and mutation lifecycle
Pi preflights parallel sibling calls in assistant source order and runs them concurrently, but sequential tools interleave start and completion. An executed call reaches `tool_result` before `tool_execution_end`; a blocked, invalid, or otherwise immediate call can reach `tool_execution_end` without any `tool_result`. Final tool-result messages are emitted later in assistant source order. The extension must coordinate overlapping execution waves rather than assume one batch per turn or one `tool_result` per start.
1. On `turn_start`, reset turn-local ordering metadata without creating a barrier for the whole turn.
2. On each `tool_execution_start`, add `toolCallId`, tool name, input, and source-order position to the current overlapping wave. If no call remains open, begin a new wave. This naturally treats each sequential execution as its own wave while grouping parallel siblings.
3. Before the first potentially mutating call in a wave executes, capture one workspace baseline. Known read-only built-ins can skip this work; `write`, `edit`, `bash`, and unknown custom tools are potentially mutating. The extension's status and waiver tools are explicitly workspace-read-only.
4. On `tool_result`, record final input, content, details, and `isError`, then mark that call terminal for barrier purposes before waiting. A failed command may have partially mutated files, so failure does not skip comparison.
5. On `tool_execution_end`, mark a call terminal only when no preceding `tool_result` did so. This accounts for blocked and argument-invalid calls without waiting for an event Pi will never emit.
6. When every call in the overlapping wave is terminal, compute one post-wave workspace snapshot. A bounded fallback releases waiters if event accounting becomes inconsistent.
7. If fingerprints differ, calculate changed paths and advance the workspace revision once. For each verification requirement whose `affects(path)` predicate matches, advance its required revision and invalidate its evidence or waiver.
8. Build analyzer requests required by applicable policies. Coalesce requests by `analyzerKey`, union and sort paths, and invoke each key once. ast-grep is the only implemented key in the MVP.
9. Fan immutable snapshots to policy evaluators. Deduplicate and format their diagnostics.
10. Attach one combined diagnostic block to a deterministic carrier with a real `tool_result`, preferably the last source-order potentially mutating result. Fall back to the last source-order participating result when the preferred call completed immediately. Preserve original `content`, `details`, `isError`, and `usage`.
11. Release every waiting handler. Use `turn_end` for cleanup and inconsistency detection, not as the normal analysis point.
An extension defect must not deadlock tool delivery. On timeout or inconsistent events, release handlers, surface a coordinator failure, and conservatively mark armed verification state unknown.
A verification command running in a wave with any other potentially mutating call must not count, even if the final workspace fingerprint is unchanged. The check may have raced a transient mutation and must be rerun in a later stable wave.
## Workspace snapshots
Use repository state, not shell-command regexes, as the primary mutation signal.
### Git workspaces
Capture a deterministic, NUL-safe map for tracked changes, staged changes, deletions, renames, symlinks, submodule markers, and untracked non-ignored files. Include the HEAD object ID and index state. Hash current content only for dirty paths relevant to at least one active policy so a second edit to an already-dirty file changes the fingerprint without hashing the clean repository. If HEAD changes, derive affected paths between the old and new commits. Compare before and after maps to derive paths changed by the execution wave.
Use Git plumbing/status commands through `pi.exec` with argv arrays, timeout, and `ctx.signal`, then strictly parse and cap their results. Ignored build artifacts such as `node_modules` and normal coverage output do not invalidate evidence unless a verification policy explicitly marks them relevant.
### Non-Git or failed snapshots
For successful `write`/`edit`, fall back to their explicit target paths. For `bash` or unknown tools in a non-Git workspace, exact changed paths cannot be proven cheaply; advance armed requirements to `unknown` and fail verification closed. Diagnostics may report that no safe path set was available rather than scanning the entire directory.
### Out-of-band changes
On a new branch state with no native-hooks history, capture the current workspace as an unarmed baseline even when it is already dirty. On restored armed state, compare the persisted project identity and fingerprint during `session_start`; also refresh at `agent_settled`. A later mismatch advances affected requirement revisions before completion gating. This catches user shell commands, editor changes, and background writers without forcing verification in a question-only session merely because files were dirty before it began.
## Analyzer coordination and performance
### One-run invariant
For one mutation execution wave, the analyzer coordinator may execute a given `analyzerKey` zero or one time. Multiple policies, languages, or findings do not create additional processes. Tests must inject a counting fake adapter and prove one call when both initial TypeScript policies apply, including parallel writes to several files.
An analyzer key includes the adapter and effective configuration. If future hooks genuinely require incompatible lint configurations, they use different keys and therefore separate documented invocations.
### ast-grep adapter
Invoke the installed executable once with all applicable rule files and changed paths, using project configuration and JSON stream output, conceptually:
```text
ast-grep scan --config ~/.config/pi-native-hooks/sgconfig.yml \
--json=stream --color=never --max-results <bound> -- <paths...>
```
Normalize JSON into rule ID, severity, message, note, path, source range, and matched text hash. Convert ast-grep's zero-based lines and columns at the rendering seam. Validate every decoded record rather than casting untrusted process output. Bound runtime, stdout, file count, finding count, and rendered diagnostics.
Keep steering rules at warning, info, or hint severity so a match does not become a process failure; the authoring skill rejects error-severity steering rules. Resolve and version-check `ast-grep` lazily once per session. A missing, incompatible, timed-out, nonzero, malformed, or killed analyzer produces an `AnalyzerFailure`; it never produces a false clean snapshot.
### Initial personal syntax policies
The initializer creates these policies and their ast-grep rules only after an approved dry run. The runtime itself ships contract fixtures and examples, not the user's live policy files. Both policies request the shared `ast-grep:personal` analyzer key and filter their own rule IDs from the same immutable snapshot.
1. **`typescript-no-non-null-assertion`**
- Match TypeScript/TSX `non_null_expression` nodes.
- Guidance: prefer a guard, refinement, assertion function, or corrected type.
- Exception: use a rule-specific `ast-grep-ignore` comment with a short rationale when an invariant is intentional.
2. **`typescript-no-dynamic-import`**
- Match dynamic `import(...)` call expressions in TypeScript/TSX.
- Guidance: prefer a static import unless lazy loading, code splitting, an optional dependency, or runtime module selection is intentional.
- Exception: use the same rule-specific suppression with a rationale.
Use separate TypeScript and TSX ast-grep rule definitions where the analyzer requires separate language declarations, but map those rule IDs to one user-facing policy ID. ast-grep's supported form allows a trailing rationale, for example:
```ts
const module = await import(path); // ast-grep-ignore: typescript-no-dynamic-import-ts -- runtime-selected plugin
```
Do not add regex-based parsing of TypeScript syntax. A later policy may validate suppression rationale formatting, but that is not required for the first slice.
### Diagnostic deduplication
Fingerprint a finding from policy ID, analyzer rule ID, canonical path, normalized range, and matched-text hash. Keep the last delivered set per file/content fingerprint. Deliver new or changed findings, drop resolved fingerprints, and avoid repeating an unchanged finding after unrelated tool calls. Cap rendered findings and include a count of omitted items.
## Verification model
### Initial personal verification policies
The authorized initializer creates two verification policy modules:
1. **`pi-native-hooks-verification`** matches Git root plus `package.json` name `pi-native-hooks`.
2. **`harness-tools-verification`** matches Git root plus `package.json` name `harness-tools`.
Both initially require these standalone commands from the matched project root:
```text
npm test
npm run typecheck
```
Each requirement owns a pure `affects(path)` predicate instead of introducing a glob dependency. The `pi-native-hooks` policy arms tests for behavior-bearing changes under `extensions/`, `src/`, `tests/`, `examples/`, and `skills/`, plus package and TypeScript manifests; its typecheck requirement covers TypeScript and package/TypeScript manifests. The harness-tools policy retains the repository-specific `extensions/`, `scripts/`, `services/`, `skills/`, `config/`, and manifest predicates documented in its module. Documentation-only changes outside each policy's declared surfaces do not stale evidence.
Trim command leading/trailing whitespace only. Do not parse shell text or accept `npm test -- ...`, `npm test && npm run typecheck`, aliases, focused scripts, or commands from another working directory. Additional exact forms require an explicit personal policy.
### Evidence lifecycle
Maintain one monotonic workspace revision. Each requirement stores its own `requiredRevision`, which remains absent until an observed path first satisfies its `affects(path)` predicate. Irrelevant later changes may advance the workspace revision without invalidating that requirement.
Each requirement is one of:
- `inactive`: this branch has not observed a relevant mutation;
- `not-run`: armed, with no evidence for its required revision;
- `passed`: the exact command fully succeeded at or after its required revision;
- `failed`: the exact command completed unsuccessfully;
- `stale`: older success exists but a later relevant mutation advanced the required revision;
- `waived`: the agent recorded a reason for this required revision; or
- `unknown`: armed workspace or event state could not be proven.
A requirement becomes valid only when all of these hold:
- it is armed;
- command and `ctx.cwd` exactly match its configured command and repository root;
- execution begins at or after the latest required revision;
- no other potentially mutating call shares its overlapping execution wave;
- no relevant mutation occurs before completion;
- Pi emits a finalized tool result with `event.isError === false`; and
- the evidence records the same project identity and required revision.
Output truncation does not imply partial execution; Pi finalizes the process before emitting the result. Normal nonzero exits, Esc aborts, and timeouts produce `isError: true` and do not count. Pi's public bash result omits the numeric exit code, so the MVP documents one narrow limitation: if the shell process itself ends through an external signal and Pi exposes a successful result, the extension cannot independently disprove completion. This is an operational completion gate, not a security attestation.
### Foreground `bash`
Capture the original command and `ctx.cwd` by `toolCallId`, then compare the finalized input after other extension handlers have run. `event.isError === false` is the success signal exposed by Pi's public finalized bash result. Record evidence only after execution-wave workspace comparison proves no relevant mutation and no potentially mutating sibling raced the check.
## Completion gate and waiver
### Forced follow-up
At every `agent_settled`:
1. refresh workspace state and advance affected requirement revisions if it changed;
2. evaluate only armed verification requirements;
3. update the TUI status; and
4. if any armed requirement is not passed or currently waived, send one custom `pi-native-hooks-verification` message with `{ deliverAs: "followUp", triggerTurn: true }`.
The message lists exact outstanding commands and states that the agent must run them or use the waiver tool with a reason. A per-settlement delivery guard prevents duplicates from concurrent callbacks, but a later settlement with the same unresolved revisions sends another reminder. That repeated behavior is intentional: otherwise Pi could settle on the second attempt without evidence.
A `context` handler removes older `pi-native-hooks-verification` messages from the model-facing copy, retaining only the latest live reminder while the gate is pending and none after it is satisfied. Session history remains auditable without allowing repeated forced turns to accumulate stale guidance in model context.
Pi has no literal task-acceptance event. This mechanism prevents the normal agent loop from remaining idle, but it cannot stop a user from closing Pi or an external client from discarding the session. Document that limitation plainly.
The gate may also fire when an agent settles after asking a question rather than claiming completion. The chosen agent-callable waiver is the escape hatch for intentionally deferred verification.
### Waiver tool
Register `pi_hooks_waive_verification` with one required `reason` string. Runtime validation trims and rejects an empty reason.
The tool:
- lists the exact outstanding requirement IDs it waives;
- records reason, timestamp, project identity, requirement IDs/revisions, and tool call in durable extension state;
- returns a visible summary that verification was skipped rather than passed;
- rejects a no-op request when no armed requirement is outstanding;
- clears the completion gate only for those requirement revisions; and
- becomes stale after the next relevant mutation.
A waiver never creates test evidence and must remain distinguishable in `/hooks-status` and session restoration. Any later path matching the requirement's `affects(path)` predicate advances its required revision and expires that waiver.
## State, observability, and failure policy
### Persistence
Append versioned `pi-native-hooks-state-v1` snapshots for meaningful transitions: policy registry digest, workspace/required revision changes, accepted evidence, failures, waivers, and coordinator uncertainty. Restore the latest valid snapshot from `ctx.sessionManager.getBranch()` on `session_start`, then compare policy digest, project identity, and live workspace fingerprint before trusting it. Reconstruct again on `session_tree` so branch navigation cannot inherit state from an abandoned branch. A previously armed policy that is now missing or fails to load remains `unknown` rather than silently clearing its gate.
Avoid persisting every analyzer finding or reminder. Diagnostic deduplication may be session-local; verification and waiver state must be durable and branch-aware.
### UI and commands
- Set a compact `pi-native-hooks` status showing diagnostic count and verification summary.
- Add `/hooks-status` to show config root health, loaded policy paths/digests, load failures, project identity, workspace revision, analyzer timing, inactive or pending checks, required revisions, last success, active waiver, and last failure.
- Keep model-visible diagnostics concise. Put verbose operational detail in the status command or TUI notification.
- Clear status and release pending barriers during `session_shutdown`.
### Failure policy
| Failure | Behavior |
|---|---|
| Config root absent with no restored armed state | Load an empty inactive registry, show initialization guidance, and do not create files automatically. |
| Config ownership/mode check fails | Import no affected file, identify the unsafe path, and keep previously armed policy state unknown. |
| New policy throws or exports an invalid value | Skip that policy, report its file, and continue valid siblings. |
| Previously armed policy is missing or fails to load | Preserve its outstanding requirement state as unknown until restored or waived; never silently accept completion. |
| ast-grep missing, incompatible, timed out, or malformed | Preserve tool success, append one analyzer warning, show degraded status, and do not claim a clean scan. |
| Policy evaluator throws | Isolate that policy, report its ID, continue siblings, and mark its evaluation degraded. |
| Workspace snapshot cannot prove armed state | Release tool results, mark affected requirements `unknown`, and force follow-up until rerun or waiver. |
| Persistence entry malformed | Ignore that entry, use the latest earlier valid state if available, and surface a warning. |
| Barrier inconsistency or timeout | Release waiters, avoid result corruption, mark verification unknown, and surface coordinator failure. |
| Result augmentation fails | Return the original result unchanged and notify the UI; never convert a successful mutation into a tool error. |
## Proposed repository layout
```text
pi-native-hooks/
├── package.json
├── tsconfig.json
├── README.md
├── extensions/
│ └── pi-native-hooks/
│ └── index.ts
├── src/
│ ├── runtime.ts
│ ├── policy.ts
│ ├── policy-loader.ts
│ ├── analysis.ts
│ ├── execution-wave.ts
│ ├── diagnostics.ts
│ ├── project-context.ts
│ ├── state.ts
│ ├── verification.ts
│ ├── workspace.ts
│ └── analyzers/
│ └── ast-grep.ts
├── tests/
│ ├── runtime.test.ts
│ ├── policy-loader.test.ts
│ ├── execution-wave.test.ts
│ ├── ast-grep.test.ts
│ ├── verification.test.ts
│ └── fixtures/
│ └── personal-config/
├── examples/
│ └── policies/
│ ├── typescript-no-non-null-assertion.ts
│ ├── typescript-no-dynamic-import.ts
│ ├── pi-native-hooks-verification.ts
│ └── harness-tools-verification.ts
├── skills/
│ └── native-hook-authoring/
│ ├── SKILL.md
│ └── references/
│ ├── contracts.md
│ └── testing.md
└── docs/
├── implementation-plan.md
└── adr/
```
Prefer colocated focused tests when a module has substantial behavior. The exact split may be simplified during implementation if files remain single-purpose.
## Delivery slices
After the planning baseline is committed, use a new worktree for implementation. Assign ownership before parallel work. Shared runtime, package manifest, and policy contract files belong to the integration owner; policy authors own only example/fixture policy and rule files. No slice may write `~/.config/pi-native-hooks` or install the Pi package until the explicit activation step in Slice 4.
### Slice 0: standalone package and trusted policy-loader proof
**Goal:** prove the repository/package shape and executable TypeScript policy contract before building the event runtime.
**Work:**
- Add the ESM package manifest, Node `>=22.19` engine, Pi extension/skill declarations, strict TypeScript configuration, and focused test command.
- Define the small `PersonalPolicy` interface and runtime structural validators.
- Implement the fixed-root policy loader behind an injected filesystem/import adapter.
- Prove native dynamic import of erasable `.ts`, type-only imports, deterministic ordering, cache-busted reload, duplicate IDs, top-level throws, invalid exports, ownership/mode rejection, and symlink policy.
- Add checked-in example policies and a fixture personal-config tree. Do not create the live config directory.
- Record the standalone package and trusted-code decision in `docs/adr/`.
**Success criteria:**
- `npm run typecheck` and focused loader tests fully execute.
- Tests run against an isolated fake HOME and never inspect or mutate the real personal config.
- One invalid policy does not hide valid siblings, and every failure identifies its path.
- The loader executes no repository-local policy and accepts no environment override for the config root.
- The Node-native loader limitations are documented and proven on the minimum supported runtime before avoiding a loader dependency.
### Slice 1: execution-wave runtime and shared analyzer seam
**Goal:** establish Pi event and one-run analyzer invariants with fakes.
**Work:**
- Add a fake Pi event harness, execution-wave coordinator, and injected fake workspace/analyzer adapters.
- Model parallel source-order starts, reverse-completion results, sequential waves, and immediate `tool_execution_end` without `tool_result`.
- Implement request coalescing, immutable snapshot fan-out, deterministic result carrier selection, barrier timeout, context-reminder pruning, and shutdown cleanup.
**Success criteria:**
- Two parallel mutations and two interested policies call one fake analyzer exactly once.
- Out-of-order results produce one workspace revision and one diagnostic augmentation.
- A blocked sibling with no `tool_result` cannot deadlock the wave.
- Sequential mutators form separate waves without waiting for future starts.
- Read-only waves invoke no workspace snapshot or analyzer.
- Failure/timeout releases every handler and leaves armed verification unknown rather than deadlocking.
### Slice 2: Git workspace, ast-grep adapter, and diagnostic examples
**Goal:** prove real mutation detection and contextual TypeScript steering.
**Work:**
- Implement Git workspace maps/fingerprints and changed-path calculation.
- Implement the one-process `ast-grep:personal` adapter against the personal `sgconfig.yml` and strict JSON normalization.
- Add TypeScript/TSX example rules and policies for non-null assertions and dynamic imports.
- Add deduplication, bounded rendering, analyzer health/status, and `/hooks-status` policy diagnostics.
- Validate `ast-grep-ignore: <rule-id> -- <reason>` exceptions.
**Success criteria:**
- Real fixture scans detect both patterns and suppress only the named rule.
- One wave touching several TS/TSX files and both policies starts one ast-grep process.
- `write`, `edit`, successful/failed `bash`, untracked, staged, deleted, renamed, symlinked, and already-dirty files change the correct fingerprint.
- Unchanged findings are not repeated after unrelated waves.
- Missing, incompatible, nonzero, timed-out, or malformed ast-grep is visible and never reported as a clean scan.
### Slice 3: verification evidence and settlement gate
**Goal:** keep Pi active until exact current evidence or a current waiver exists.
**Work:**
- Implement project matching, pure path predicates, observed-mutation activation, and example policies for `pi-native-hooks` and `harness-tools`.
- Track exact foreground bash attempts and Pi's finalized success state.
- Persist policy digest plus branch-aware workspace/requirement revisions, evidence, failures, and waivers.
- Handle changed, missing, or failed previously-armed policies without silently accepting completion.
- Implement settlement refresh, forced follow-up delivery, context pruning, status, and waiver tool.
**Success criteria:**
- A new state-free session remains unarmed even when relevant files were already dirty at startup.
- Exact standalone checks satisfy only their matching armed project requirements.
- Focused, altered, combined, failed, aborted, timed-out, wrong-cwd, concurrent, and stale commands do not satisfy the gate.
- A potentially mutating sibling invalidates a racing check even when final content is unchanged.
- `agent_settled` sends one follow-up per pending settlement and none after current evidence or waiver.
- A non-empty waiver is visible, branch-aware, revision-scoped, and invalidated by the next relevant mutation.
- Reload, branch navigation, policy digest change, and out-of-band edits cannot revive stale evidence.
### Slice 4: authoring skill, activation, and independent review
**Goal:** validate authoring and real Pi behavior before normal use.
**Work:**
- Create the package-managed `native-hook-authoring` skill with progressive disclosure for policy creation, revision, validation, and troubleshooting.
- Add `/hooks-init --dry-run` and `/hooks-doctor`; any command that writes personal config requires explicit interactive approval and preserves existing files.
- Run an extension integration scenario with parallel writes, one ast-grep run, diagnostics, stale verification, forced follow-up, successful checks, invalidation, and waiver.
- Run unfiltered `npm run typecheck` and `npm test`.
- Have an agent other than the implementer review standards, trust handling, event races, failure behavior, and this approved plan.
- Only after review and explicit user approval, install the local-path Pi package, initialize selected personal policies, run `/hooks-doctor`, and `/reload`.
**Success criteria:**
- The skill points to the package's policy contract rather than duplicating it.
- A fresh agent can create a fixture policy without adding analyzer execution to its evaluator.
- Dry runs enumerate every external file and package-setting change before mutation.
- Existing personal config is never overwritten; partial initialization rolls back newly created files.
- Tests record actual analyzer invocation counts and full command completion.
- Independent review has no unresolved blockers.
- The activated extension reports exactly one runtime, all intended personal policy digests, and a healthy analyzer after `/reload`.
## Focused test matrix
| Area | Required cases |
|---|---|
| Policy loader | fixed root; native TS; type-only import; sorted files; duplicate IDs; invalid export; top-level throw; owner/mode; symlink; cache-busted reload; changed digest |
| Wave ordering | one/many siblings; source-order starts; reverse completion; sequential waves; immediate end without result; read-only; failed partial mutation; missing terminal timeout; cleanup |
| Analyzer coalescing | two policies same key; many paths; no applicable paths; different future keys; adapter throw/timeout/malformed output |
| ast-grep | TS and TSX positives; static import negative; postfix `!` positive; logical `!` negative; justified suppression; Unicode ranges; result cap |
| Workspace | clean/dirty/staged/untracked/ignored/deleted/renamed/symlinked/binary/already-dirty; non-Git fallback; external edit |
| Delivery | original result fields preserved; one carrier; dedupe; resolved finding; truncation; analyzer warning |
| Foreground evidence | exact success; nonzero; abort; timeout; wrong cwd; focused flag; combined command; mutating sibling with/without net diff; documented external-signal limitation |
| Gate | pre-existing dirty baseline inactive; observed relevant mutation armed; irrelevant mutation unchanged; partial pass; failed; stale; unknown; repeated settlements; no duplicate within settlement; idle follow-up; context pruning |
| Waiver | empty rejection; no-op rejection; scoped requirement revisions; branch persistence; status visibility; later relevant invalidation |
| Session | startup without state; valid active-branch restore; malformed/latest-invalid entry; fork/reload/session-tree; project identity mismatch; fingerprint drift |
| Activation | Pi package manifest; local-path dry-run/install; fake HOME initialization; existing config preservation; partial rollback; doctor; exactly one runtime after reload |
## Performance and safety acceptance
- No analyzer process for a read-only execution wave or when no changed path is relevant.
- At most one invocation per analyzer key per overlapping execution wave, enforced by tests.
- Git work is proportional to dirty/untracked paths, not every clean tracked file.
- Analyzer and snapshot commands have abort signals, timeouts, and output/result caps.
- Policy evaluation exceptions cannot prevent sibling policies or original tool-result delivery.
- Policy modules receive immutable analysis data, but their top-level code remains explicitly trusted and unsandboxed inside Pi's outer sandbox.
- Diagnostics never change a successful tool result into an error.
- Verification never becomes current from incomplete, ambiguous, concurrent, or stale execution.
## Documentation and review artifacts
After approval, implementation should add:
- `docs/adr/0001-standalone-runtime-and-trusted-policies-27AUG2026.md` for repository ownership, native policy loading, shared analyzers, and the completion gate;
- concise user documentation for installation, config trust, supported TypeScript syntax, status, waiver semantics, exact commands, exceptions, and known completion limitations;
- the `native-hook-authoring` skill; and
- a repository-local `docs/todos.md` entry for any reproducible defect discovered while testing the extension on itself.
## Planning validation completed
- Pi 0.84.3 documentation and installed source confirm parallel preflight ordering, concurrent execution, `tool_result` result augmentation, source-order final result messages, and `agent_settled` custom-message follow-ups.
- Installed Pi source also confirms the execution-wave correction: blocked or argument-invalid calls can emit `tool_execution_end` without invoking `tool_result`, while sequential tools do not expose future starts before the current result finishes.
- 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 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.
## Approval gate
The user accepted these consequences on 27 August 2026:
1. Every personal `.ts` policy is trusted arbitrary code running in Pi's process; structural validation does not sandbox imports or top-level side effects, and a nonconforming policy can bypass the toolkit's one-run analyzer coordinator.
2. The MVP supports only Node's erasable TypeScript subset for personal modules and reloads policies on `/reload`, not continuously.
3. Pending verification forces a new turn at every settlement, including settlements after the agent asks a question.
4. A state-free session treats pre-existing dirty files as an unarmed baseline; only observed relevant mutations or restored armed state require checks.
5. The agent can waive its own verification requirement, but the reason remains visible and the waiver expires on a relevant mutation.
6. Exact command matching intentionally rejects convenient combined or focused commands.
7. Foreground success trusts Pi's public `isError` state and carries the documented external-signal ambiguity.
8. ast-grep failure degrades steering but does not block file mutation; armed workspace uncertainty does block verification completion.
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. 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.
@@ -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.
+4
View File
@@ -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.
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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 === "@_jb/pi-native-hooks" }, requirements: [
{ id: "tests", command: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,2 @@
ruleDirs:
- ast-grep/rules
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"paths": { "@_jb/pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] }
},
"include": ["policies/*.ts"]
}
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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 === "@_jb/pi-native-hooks" }, requirements: [
{ id: "tests", command: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -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);
}
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@_jb/pi-native-hooks",
"version": "0.1.0",
"description": "Native Pi hooks runtime for shared diagnostics and verification gates.",
"type": "module",
"license": "MIT",
"homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/native-hooks",
"repository": {
"type": "git",
"url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git",
"directory": "extensions/native-hooks"
},
"bugs": {
"url": "https://git.jb.codes/jb/harness-tools/issues"
},
"engines": {
"node": ">=22.19.0"
},
"files": [
"extensions/**/*",
"src/**/*",
"skills/**/*",
"examples/**/*",
"docs/**/*",
"README.md",
"LICENSE"
],
"keywords": [
"pi-package",
"pi-extension",
"hooks",
"diagnostics",
"verification"
],
"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",
"prepublishOnly": "bun run typecheck && bun run test"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "catalog:pi-peer",
"typebox": "catalog:pi-peer"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "catalog:",
"@types/node": "catalog:",
"tsx": "catalog:",
"typebox": "catalog:",
"typescript": "catalog:"
},
"publishConfig": {
"access": "public",
"registry": "https://git.jb.codes/api/packages/jb/npm/"
}
}
@@ -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.
@@ -0,0 +1,16 @@
# Policy contracts
The authoritative declarations are exported by `@_jb/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 "@_jb/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.
@@ -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.
+21
View File
@@ -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<AnalysisSnapshot> }
export class AnalysisCoordinator {
constructor(private readonly adapters: ReadonlyMap<string, AnalyzerAdapter>) {}
async run(requests: readonly AnalysisRequest[], revision: number, signal?: AbortSignal): Promise<ReadonlyMap<string, AnalysisSnapshot>> {
const grouped = new Map<string, Set<string>>();
for (const request of requests) { const paths = grouped.get(request.analyzerKey) ?? new Set<string>(); 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<T>(value: T): T { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const item of Object.values(value as Record<string, unknown>)) deepFreeze(item); } return value; }
export function readonlyMap<K, V>(entries: readonly (readonly [K, V])[]): ReadonlyMap<K, V> { 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; } }); }
@@ -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<AnalyzerFailure | undefined>;
constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {}
prepare(signal?: AbortSignal): Promise<AnalyzerFailure | undefined> { return this.versionChecked ??= this.checkVersion(signal); }
async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise<AnalysisSnapshot> {
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<AnalyzerFailure | undefined> {
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<string, unknown>;
const range = row.range as Record<string, unknown>; const start = range?.start as Record<string, unknown>; const end = range?.end as Record<string, unknown>;
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) }); }
@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
import type { Diagnostic } from "./policy.ts";
export class DiagnosticDelivery {
private delivered = new Set<string>();
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");
}
@@ -0,0 +1,30 @@
export interface ObservedToolResult { readonly toolCallId: string; readonly toolName: string; readonly input: Record<string, unknown>; 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<string, { readonly content?: readonly unknown[]; readonly details?: unknown; readonly isError?: boolean; readonly usage?: unknown }>;
interface ActiveWave { id: string; turnIndex: number; calls: WaveCall[]; finalized: boolean; timer?: NodeJS.Timeout; promise: Promise<WavePatches>; 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<WavePatches>, 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<WavePatches>((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()); }
}
@@ -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<string[]> { 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"); }});
}
@@ -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<string[]>;
readFile(path: string): Promise<Buffer>;
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<string | undefined> {
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<boolean> {
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<PolicyRegistry> {
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<string, string>();
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 });
}
+44
View File
@@ -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<string, AnalysisSnapshot>, 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<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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" };
}
@@ -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<ExecResult>;
export async function discoverProject(cwd: string, exec: Exec, signal?: AbortSignal): Promise<ProjectContext> {
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 });
}
+103
View File
@@ -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<string, WorkspaceSnapshot>(); private readonly attempts = new Map<string, readonly CheckAttempt[]>();
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<void> {
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<void> {
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<WavePatches> {
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<readonly Diagnostic[]> {
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<void> { 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}`); }
}
+14
View File
@@ -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<string, unknown>;
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<string, unknown>; 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 };
}
@@ -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<string, ActiveRequirement>();
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"); }
+62
View File
@@ -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<string, string>; 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<WorkspaceSnapshot> {
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<string, string>();
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<readonly string[]> {
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<string, string> {
const tokens = output.split("\0"); const result = new Map<string, string>();
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<string, string> { const result = new Map<string, string>(); 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<string> {
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<string, unknown>): readonly string[] { if ((toolName === "write" || toolName === "edit") && typeof input.path === "string") return Object.freeze([input.path]); return Object.freeze([]); }
@@ -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<string, unknown>).set, undefined); });
@@ -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"]); });
@@ -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); });
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,8 @@
import type { VerificationPolicy } from "@_jb/pi-native-hooks/policy";
const manifests = new Set(["package.json", "bun.lock", "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 === "@_jb/pi-native-hooks" }, requirements: [
{ id: "tests", command: "bun run test", cwd: "project-root", affects: behavior },
{ id: "typecheck", command: "bun run typecheck", cwd: "project-root", affects: types },
] } satisfies VerificationPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> for an intentional dynamic import.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,7 @@
import type { DiagnosticPolicy } from "@_jb/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} -- <short rationale> when the invariant is intentional.`, matchedTextHash: finding.matchedTextHash })),
} satisfies DiagnosticPolicy;
@@ -0,0 +1,2 @@
ruleDirs:
- ast-grep/rules
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"paths": { "@_jb/pi-native-hooks/policy": ["__PI_NATIVE_HOOKS_PACKAGE_ROOT__/src/policy.ts"] }
},
"include": ["policies/*.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.0"); });
@@ -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)));
});
@@ -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<string, Array<(...args: unknown[]) => 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" }] });
});
@@ -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); });
@@ -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"); });
@@ -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")); });
+15
View File
@@ -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"]
}