# Working in this repo ## PR policy - **Every PR must reference a pre-existing GitHub issue.** GitHub does not enforce this — open the issue first, link it from the PR. - **One PR = one issue.** If an issue can't be delivered in a single reviewable PR, split it into smaller issues first and open one PR per child issue. Don't bundle unrelated changes into a single PR just because they share a parent issue. - Signs an issue needs granulating: it lists multiple independent measures ("do A, B, C") or touches unrelated areas. - When granulating, open the child issues, link them from the parent, and convert the parent into a tracking issue (preserves context and links children). ## Branch naming `/`, kebab-case. Examples: `websearch/region-filter`, `webfetch/textdecoder-windows-1250`, `chore/release-action`. ## Commit / PR title format PRs are squash-merged. **The PR title is used verbatim as the squash commit subject**, so the PR title must itself be a valid [Conventional Commits](https://www.conventionalcommits.org/) subject. release-please reads these subjects to decide whether (and how) to cut the next release. ### Allowed types Use only the types release-please recognizes. With this repo's default config (no `changelog-sections` override in `release-please-config.json`), the defaults apply: `feat`, `fix`, `perf`, `revert`, `docs`, `style`, `chore`, `refactor`, `test`, `build`, `ci` - `feat:` → minor bump, **Features** section - `fix:` → patch bump, **Bug Fixes** section - `perf:` → patch bump, **Performance Improvements** section - `revert:` → patch bump, **Reverts** section - `docs:`, `style:`, `chore:`, `refactor:`, `test:`, `build:`, `ci:` → patch bump if they appear alone, but **hidden from CHANGELOG by default** (won't produce an entry) - `!:` or a `BREAKING CHANGE:` footer → major bump Note: `deps` is **not** a default release-please type. Dependabot PRs in this repo use `ci(deps): …` for GitHub Actions and `chore(deps): …` for npm packages (scope `deps` in both cases). Both types are hidden from the changelog. ### Common mistake: unknown types produce no changelog entry and no version bump A subject like `security(webfetch): ...` looks conventional but `security` isn't a recognized type, so release-please neither bumps the version nor records a changelog entry — even for user-visible fixes. Use `fix:` with a `security` scope instead, so the change lands in **Bug Fixes** and triggers a patch: - ❌ `security(webfetch): re-validate URL on every redirect hop` - ✅ `fix(security): re-validate URL on every redirect hop in webfetch` (The trailing `(security)` annotation seen in some prior commits is a soft convention, not enforced by any lint or template.) ### Examples from this repo - `feat(websearch): add region filter` - `fix(webfetch): handle windows-1250 via TextDecoder` - `ci(deps): bump googleapis/release-please-action from 4.4.1 to 5.0.0` - `docs(agents): require one-issue-one-PR and document granulation` ## Design rationale This file holds policy: how to work in the repo, and what not to propose. The reasons behind the design live in [`docs/adr/`](docs/adr/README.md) — one record per decision, with the options that were rejected and the consequences. Read the records before you propose a change to fetching, the SSRF guard, the markers in returned text, the HTML converters, or the extraction pre-pass. Each one names the file that implements it. The scope rules below, and the deferred-tool-loading analysis further down, keep their decisions here; they get no record of their own. ## Out of scope (deliberately rejected, do not propose) - **Build step.** Pi loads raw `.ts` via jiti — no Webpack, Rollup, tsc emit, etc. - **HTML→markdown npm libraries** (Turndown, etc.). Pandoc/w3m via subprocess is the design. See [ADR 0005](docs/adr/0005-subprocess-html-to-markdown.md). - **API-key / account-based search providers** (Tavily, Brave, Exa, Perplexity, Anthropic web_search, Ollama Web Search). Zero-key is the project's reason to exist. - **Headless browsers, JS execution** (Playwright, Puppeteer, etc.). Shell-only constraint. ## Bar for new tools Every registered tool's name, description, and schema is loaded into every agent turn that imports this package. New tools are not free — they're a recurring prompt-token cost paid by every user, including users who never invoke them. Before proposing a new tool, in order: 1. Can existing primitives plus one sentence in the caller's prompt do it? — do nothing. 2. Can existing primitives do it but produce wasteful output? — improve the primitive with a sane default. 3. Is it specific to one site, API, or CLI? — belongs in a personal skill (`~/.pi/agent/skills/`) or a separate package, not here. `webfetch` deliberately does not contain per-host routing inside this package — no `if hostname === "github.com"` branches, and no autodetection shims like "if `gh` is on PATH, reroute github.com URLs through it." The agent picks the right CLI for the host; this package provides general primitives only. ### Deferred tool loading does not lower this bar Since pi 0.80.7 / 0.80.9, extensions can register tools inactive and activate them mid-session with `pi.setActiveTools()`. On models with native support (Anthropic 4.5+, GPT-5.4+) the added schemas load at the tool-result position without invalidating the cached prompt prefix. So the "recurring cost paid by every user" claim above is, strictly, avoidable above some tool count. See [Dynamic Tool Loading](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#dynamic-tool-loading). It is not avoidable at two tools. The pattern needs a loader tool kept active for the whole session, so hiding N tools costs N+1 registrations plus one permanently-present schema — net-negative until N is well past 2. Activating a tool that carries `promptSnippet` or `promptGuidelines` also rebuilds the system prompt, which invalidates the prefix regardless of schema deferral. Written down so the tradeoff is not re-derived, not as an invitation. The bar above stands unchanged. Revisit only if this package ever carries enough tools that the loader overhead is obviously worth paying. ## GitHub Actions naming - **Filename:** kebab-case, named after purpose. Tool names are fine when the tool *is* the purpose (e.g. `release-please.yml`, `dependabot.yml`). - **Workflow `name:`:** lowercase, kebab-case, mirrors the filename stem. - **Job ID** (the YAML key): kebab-case, names the gate (e.g. `typecheck`, `publish-npm`). - **Job `name:`:** omit unless it adds info beyond the ID. It adds info when the ID alone is ambiguous, e.g. matrix legs: `name: node ${{ matrix.node }}`. Rationale: the workflow/job ID is what appears in PR required-status-check config and failure notifications, so it must match the filename and be greppable. ## Peer dependency floors The three peer ranges in `package.json` have a `>=` floor. Each floor is the version that CI resolved when the floor was set (issue #213): `pi-coding-agent` 0.83.0, `pi-tui` 0.83.0, `typebox` 1.3.7. - Keep the top of each range open. A minor release upstream then needs no release here. - Raise a floor only when this package starts to use an API that the older version does not have. Do not raise a floor to track the latest upstream version. - A floor is not a drift signal. Pi re-scoped its npm packages once already (`@mariozechner/*` → `@earendil-works/*`, issue #162), and the range kept resolving the whole time. Section 6 of `scripts/metrics.sh` is the drift signal. `devDependencies` are separate: the `@earendil-works/*` entries stay at `*` so the dev tree tracks the latest upstream version, and `typebox` stays pinned to the exact version pi bundles. ## Release checks Releases are automatic: release-please opens a release PR, and the merge publishes to npm. No CI job speaks to an OpenAI/Codex provider. Those providers validate every tool schema before the turn starts, so a bad schema stops the whole session for every user. #239, #240 and #241 each reached users this way. Do these two checks by hand. **Before you merge a release PR**, load the extension on an `openai-codex` session and run one call of each tool: 1. Link the working copy: `ln -s "$(pwd)" ~/.pi/agent/extensions/pi-web-tools`. 2. Start pi with an `openai-codex` provider. Run `/reload`. 3. Run one `websearch` call. 4. Run one `webfetch` call with `offset` set, to prove the pagination round-trip. This proves the tools work end to end on the provider. Record the result in the release PR. **It is not a schema gate.** Measured on 2026-08-04 with pi and `gpt-5.5`: a session accepted, and called, tools whose schemas broke #239 (no `additionalProperties`), #241 (`required` omits a key) and one that used `allOf`, which the OpenAI API rejects by name. So pi's `openai-codex` path does not apply strict validation, and a green smoke check says nothing about whether a schema is strict-legal. Why this differs from the sessions that broke in #239 and #241 is not known — pi may sanitize schemas now, or the provider may have changed. Until that is understood, treat `test/schema.test.ts` and its recorded fixture as the only schema gate. **When you change a tool schema**, also run `bun run probe:strict` with a real key and commit the new `test/strict-contract.json`. The probe asks the provider which keywords and rules it accepts. `test/schema.test.ts` holds the ruleset to those recorded answers, so an unverified change to `STRICT_ALLOWED` fails the test suite. ## Writing style English prose — docs, code comments, commit and PR text, issues, user-visible strings — follows [Simplified Technical English](https://www.asd-ste100.org/) (ASD-STE100): one meaning per word, active voice, imperative for instructions, simple tenses, one instruction per sentence (max 20 words), no jargon or metaphor. Code identifiers are exempt.