From 115cf34aedf431abe68f2214d2b8dc6ece21119c Mon Sep 17 00:00:00 2001 From: Laurent FRANCOISE Date: Tue, 15 Sep 2026 23:00:41 +0200 Subject: [PATCH 1/3] feat(pi): start sessions with the substrate tool surface A Pi session now activates only the MCP `substrate` preset tools plus openlore_configure and the new openlore_activate_tools. The activator turns on task groups (specs, memory, review, quality, inspect) by group or tool name. Tools stay registered, so the parity guard and daemon are unchanged. - pi.toolSurface: "all" in .openlore/config.json keeps every tool active - one-line prompt snippets replace the duplicated full descriptions - CI budgets bound the Pi standing context for the lean and all surfaces - a test keeps the Pi lean set equal to TOOL_PRESETS.substrate Closes #505. Decision: ADR-0034 (848b360d). Co-Authored-By: Claude Opus 5 --- docs/install.md | 11 + examples/pi/README.md | 12 +- .../.openspec.yaml | 2 + .../design.md | 144 +++++++++ .../proposal.md | 52 ++++ .../specs/mcp-quality/spec.md | 169 +++++++++++ .../tasks.md | 35 +++ ...ith-the-substrate-tool-surface-and-on-d.md | 24 ++ openspec/specs/mcp-quality/spec.md | 144 ++++++++- src/core/services/config-schema.ts | 2 +- src/core/services/tls-coverage.test.ts | 8 +- src/pi/extension.test.ts | 255 +++++++++++++++- src/pi/extension.ts | 274 +++++++++++++++++- src/types/index.ts | 7 + 14 files changed, 1116 insertions(+), 23 deletions(-) create mode 100644 openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/design.md create mode 100644 openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/proposal.md create mode 100644 openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/specs/mcp-quality/spec.md create mode 100644 openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/tasks.md create mode 100644 openspec/decisions/adr-0034-pi-starts-with-the-substrate-tool-surface-and-on-d.md diff --git a/docs/install.md b/docs/install.md index 7465fdd5..91a0133b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -151,6 +151,17 @@ The extension registers the navigation tools (`openlore_orient`, `openlore_searc caches. Requires Pi ≥ 0.78.1 and one `openlore analyze` beforehand. Full detail in [`examples/pi/README.md`](../examples/pi/README.md). +A Pi session starts with the same lean tool set Claude Code gets by default (the `substrate` +preset), plus `openlore_configure` and `openlore_activate_tools`. The agent calls +`openlore_activate_tools` to turn on the `specs`, `memory`, `review`, `quality`, or `inspect` +group. To keep every tool active from the start, set: + +```json +{ "pi": { "toolSurface": "all" } } +``` + +in `.openlore/config.json`. + > **What the file contains.** `.pi/extensions/openlore.js` is a four-line re-export shim, not a > copy of the extension. The shipped extension is plain `tsc` output whose relative imports only > resolve inside the openlore package, so a copy fails to load. The shim's target path is diff --git a/examples/pi/README.md b/examples/pi/README.md index 588d5aeb..33b5dcb3 100644 --- a/examples/pi/README.md +++ b/examples/pi/README.md @@ -13,10 +13,14 @@ fresh while you edit. - **Context injection** (no tool call needed): each session starts grounded with the architecture digest (`CODEBASE.md`), the spec-domain index, and a task-specific `orient` on your first message. -- **Native tools**: the navigation surface as Pi tools — - `openlore_orient`, `openlore_search_code`, `openlore_get_subgraph`, - `openlore_trace_execution_path`, `openlore_analyze_impact`, - `openlore_suggest_insertion_points`, `openlore_get_function_skeleton`. +- **Native tools**: a session starts with the same lean set Claude Code gets + by default (the `substrate` preset) — `openlore_orient`, + `openlore_search_code`, `openlore_get_subgraph`, `openlore_analyze_impact`, + `openlore_recall`, `openlore_verify_claim`, `openlore_blast_radius`, and more. + The agent calls `openlore_activate_tools` to turn on a group when a task needs + it: `specs`, `memory`, `review`, `quality`, or `inspect`. Set + `"pi": { "toolSurface": "all" }` in `.openlore/config.json` to keep every tool + active. - **Config wizard**: interactive setup on first run, or anytime via `/openlore` slash command or `openlore_configure` tool. diff --git a/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/.openspec.yaml b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/.openspec.yaml new file mode 100644 index 00000000..96db9a43 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/design.md b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/design.md new file mode 100644 index 00000000..34837efb --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/design.md @@ -0,0 +1,144 @@ +## Context + +See proposal.md — Why. Current state in `src/pi/extension.ts`: + +- At load, the extension registers 42 `NAV_TOOLS`, `openlore_prepare_spec_generation`, + `openlore_prepare_spec_repair`, and `openlore_configure`. Pi activates each newly registered + extension tool by default. +- Each `NAV_TOOLS` entry sets `promptSnippet: tool.description`, so Pi's "Available tools" section + repeats every full description. +- The file header states that presets and families are MCP-wire concepts that Pi does not use. + This change reverses that statement. +- Pi host API (`@earendil-works/pi-coding-agent` ^0.84): `getActiveTools()`, `getAllTools()`, + `setActiveTools(names)`. `setActiveTools` replaces the full active set and rebuilds the system + prompt; the change applies from the next model request. `promptGuidelines` of inactive tools are + left out of the prompt. +- The daemon runs `--preset full` and dispatches every tool. It needs no change. +- `pi.spawnDaemon` is read directly from `.openlore/config.json` (`piMaySpawnDaemon`), without + `readConfig`, so it works before a provider is configured. + +## Goals / Non-Goals + +**Goals:** +- Lean standing context by default, with every current tool still reachable in one call. +- Keep the parity guard and the daemon contract unchanged in intent. +- Keep one config switch that restores today's behavior exactly. + +**Non-Goals:** +- Deactivating groups during a session. +- Changing the MCP presets, the capability families, or the daemon. +- Automatic activation from task text or from tool results. +- Persisting activation across sessions. + +## Decisions + +### D1. Register everything at load, activate a subset at `session_start` + +Keep all registrations at load. In `session_start`, read `pi.toolSurface`, snapshot +`pi.getActiveTools()`, and call `setActiveTools` with the snapshot minus the non-lean OpenLore tools. + +- Why: the parity guard, `getAllTools()`, and the daemon health check (`missingDaemonTools`) stay + unchanged; activation is a single host call. +- Alternative: register group tools lazily from the activator. Rejected — config is per-`cwd` and + unknown at load, registration order would depend on the session, and the parity guard would need + a second code path. + +### D2. Task groups, not capability families + +The `navigate` family holds 57 tools, and the substrate preset spans four families, so families do +not produce useful activation units. Group table (a new exported constant, next to `NAV_TOOLS`): + +| Group | Tools | +|-------|-------| +| lean (always active) | `orient`, `search_code`, `get_subgraph`, `trace_execution_path`, `find_path`, `analyze_impact`, `suggest_insertion_points`, `get_function_skeleton`, `get_map`, `get_landmarks`, `recall`, `verify_claim`, `blast_radius`, `prepare_spec_generation`, `prepare_spec_repair`, `configure`, `activate_tools` | +| `specs` | `search_specs`, `get_spec`, `list_spec_domains`, `search_unified`, `check_spec_drift`, `audit_spec_coverage` | +| `memory` | `remember`, `record_decision`, `list_decisions`, `approve_decision`, `reject_decision`, `sync_decisions` | +| `review` | `structural_diff`, `select_tests`, `get_test_coverage`, `briefing_since`, `certify_public_surface` | +| `quality` | `get_refactor_report`, `get_health_map`, `get_critical_hubs`, `get_god_functions`, `get_architecture_overview`, `get_surprising_connections`, `find_clones`, `get_style_fingerprint` | +| `inspect` | `get_function_body`, `get_file_dependencies`, `analyze_error_propagation`, `analyze_env_impact` | + +The lean row is the MCP `SUBSTRATE` set plus the two Pi-only utility tools. A test derives the +expected lean set from the MCP preset definition, so the two surfaces cannot drift (spec: +PiDefaultToolSurfaceIsLean). + +- Alternative: one group per family. Rejected — see above. +- Alternative: many small groups. Rejected — more names for a small model to choose from, and more + activations, so more prompt-cache misses. + +### D3. Activator contract + +`openlore_activate_tools({ names: string[] })`. Each name resolves to a group name or to the group of +a tool name (the `openlore_` prefix is optional). The handler computes the union of the group tools, +removes the tools in the host-excluded set, and calls `setActiveTools(current ∪ added)`. It returns +the activated tools, the groups that were already active, and the host-excluded tools. An unknown +name returns an error that lists the groups and activates nothing (all-or-nothing validation). + +The description lists each group with its tool names, one line per group. Its guideline tells the +agent to call the activator before it uses a tool from a group, and when an OpenLore result names a +tool that is not available. + +- Why tool names are accepted: `orient` output and the injected architecture digest name tools such + as `check_spec_drift`. A small model can copy that name directly. + +### D4. Host exclusion snapshot + +The host `--tools` allowlist filters the initial active set, but `setActiveTools` does not check the +allowlist. At `session_start`, before the lean surface is applied, the extension records the OpenLore +tools that are **not** active. Those tools form the host-excluded set, and the activator never adds +them. In `"all"` mode the extension does not call `setActiveTools` for OpenLore tools, except to +deactivate the activator. + +### D5. `pi.toolSurface` read like `pi.spawnDaemon` + +Add `toolSurface?: 'lean' | 'all'` to the Pi config type. Read it with a direct JSON read of +`.openlore/config.json` (the `piMaySpawnDaemon` pattern), because `readConfig` returns null until a +provider is configured. Any value other than the exact string `"all"` selects lean. No environment +variable: YAGNI; add one only if a host needs a per-process override. + +### D6. Short snippets and a budget + +Add a `PI_TOOL_SNIPPETS` map next to `NAV_TOOLS` (one line, at most 90 characters, trigger-first +like the current descriptions); a test requires one entry per `NAV_TOOLS` tool and no stale entry. +A map keeps the 42 entries in one reviewable block instead of 42 scattered field edits. The budget +estimator uses the pattern of `STANDING_CONTEXT_BUDGETS`: characters of the description, JSON +schema, snippet, and guidelines, divided by 4, summed over the registered tools of each surface. It +records the measured baseline with about 7% headroom for the `lean` and `all` surfaces. + +### D7. Lean default from the first merge, by parity + +The default is `"lean"` when the change ships. The Claude Code install wires +`openlore mcp --preset substrate` (`LEAN_DEFAULT_PRESET`, ADR-0023). Pi uses the same tool set, so Pi +does not choose a new default; it follows the MCP default. The test in D2 keeps the two sets equal. +If `LEAN_DEFAULT_PRESET` changes later, the Pi lean set changes with it. + +Claude Code also loads MCP tool schemas on demand, so its standing cost is lower than the preset +alone suggests. The Pi host has no deferral; the activator gives Pi the same effect. + +- Alternative: a separate small-model Pi benchmark before the flip. Rejected — `bench/PROTOCOL.md` + gates changes to the MCP default preset, and this change does not modify that preset. A Pi runner + arm would add a large harness change for a surface that is already cleared. `pi.toolSurface: "all"` + remains the escape if a host model does not use the activator. + +## Risks / Trade-offs + +- [Prompt-cache miss on each activation] → Few, coarse groups; activation is additive, with no + deactivate cycle. +- [Agent does not know that an inactive tool exists] → Activator description lists all tools; tool + names are accepted; guideline covers "named tool not available". +- [Small model never calls the activator, so it loses capability] → The lean set is the benchmarked + MCP default; the `"all"` escape stays and is documented. +- [Pi API behavior changes (default activation, allowlist handling)] → Tests use the mocked + `ExtensionAPI` for the contract; the existing real-loader CI job checks that the extension loads. +- [Group membership is a judgment call] → The coverage test forces a decision for each new tool; + membership can change without a spec change. + +## Migration Plan + +1. Merge with the default `"lean"`. Release notes name the change and the `pi.toolSurface: "all"` + escape. +2. Rollback: set `pi.toolSurface: "all"` in the project, or revert the commit. + +## Open Questions + +- Final group names and membership may change after use in real sessions. This needs no spec change, + because the spec does not name the groups. diff --git a/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/proposal.md b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/proposal.md new file mode 100644 index 00000000..57b5925d --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/proposal.md @@ -0,0 +1,52 @@ +## Why + +The Pi extension registers 45 tools, and all of them are active for the whole session. Each tool puts +its schema, a copy of its description (`promptSnippet`), and a guideline bullet into the system prompt. +The MCP default surface is the 15-tool `substrate` preset (ADR-0023). Pi has no lean default, so smaller +local models pay a larger standing-context cost and must choose from a flat list of 45 tools +(GitHub issue #505). The Pi host already supports runtime tool activation (`setActiveTools`), so a lean +default no longer requires removing tools. + +## What Changes + +- At session start the Pi extension SHALL activate only the lean surface: the tools of the MCP + `substrate` preset plus `openlore_configure`. Every other OpenLore tool stays registered but inactive. +- Add one activator tool, `openlore_activate_tools`. It activates named, task-oriented tool groups + (`specs`, `memory`, `review`, `quality`, `inspect`) for the rest of the session. Its description + lists every group and the tools in it, so the agent can discover inactive tools. +- The groups are task groups, not the six capability families. The `navigate` family holds 57 tools + and is too coarse to activate as one unit. +- Add the `.openlore/config.json` key `pi.toolSurface` (`"lean"` | `"all"`). `"all"` restores the + current behavior (every tool active, no activator). +- Replace the duplicated `promptSnippet` (today it is the full tool description) with a short + one-line snippet, and bound the Pi standing-context cost with a CI budget. +- The lean default ships with the change, for parity with the Claude Code default: `openlore install` + wires `openlore mcp --preset substrate` (`LEAN_DEFAULT_PRESET`), and ADR-0023 benchmarked that + surface. Pi adopts the same set; it does not introduce a new default surface. +- Record a decision that reverses the current source position that presets are an MCP-only concept + that Pi does not use. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `mcp-quality`: the Pi parity guard defines "present in the Pi surface" as registered (active or + activatable). New requirements cover the Pi lean default surface, group activation, the + `pi.toolSurface` escape, and a standing-context budget for the Pi surface. + +## Impact + +- `src/pi/extension.ts`: group table, `session_start` activation, activator tool, short snippets. +- `src/pi/extension.test.ts`: group-coverage guard, activation behavior, budget guard; the existing + parity guard stays unchanged in intent. +- `src/types/index.ts`: `pi.toolSurface` config type. +- Pi user documentation and the Pi install notes. +- No new benchmark: the MCP default preset does not change, so `bench/PROTOCOL.md` does not apply. + The Pi lean set is derived from that preset. +- No daemon change: the daemon keeps `--preset full` and dispatches any known tool. +- Host behavior: activating a group changes the tool list and the system prompt, so the provider + prompt cache misses once for each activation. diff --git a/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/specs/mcp-quality/spec.md b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/specs/mcp-quality/spec.md new file mode 100644 index 00000000..ec42b9f6 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/specs/mcp-quality/spec.md @@ -0,0 +1,169 @@ +## MODIFIED Requirements + +### Requirement: PiSurfaceParityIsGuarded + +The Pi extension's native tool surface SHALL be held in verified parity with the MCP tool surface +in both directions: every Pi-surfaced tool SHALL be dispatchable by the daemon (the existing +direction), AND every dispatchable conclusion tool SHALL either be present in the Pi surface or +appear on a named, source-commented exclusion list stating why its omission is deliberate. A tool +is present in the Pi surface when the extension registers it, whether the tool is active at session +start or becomes active through a tool group. A CI test SHALL fail when a conclusion tool is neither +surfaced nor excluded, so a new MCP tool cannot silently drift out of Pi — the same +fails-until-you-decide discipline the tool-contract classification test already enforces. Where a +tool's input contract differs between surfaces (e.g. an enum of claim kinds), the Pi declaration +SHALL NOT silently omit capabilities the MCP handler supports; a deliberate narrowing SHALL be +stated in source. + +#### Scenario: A new conclusion tool cannot skip the Pi decision + +- **GIVEN** a new MCP tool classified `conclusion` in `TOOL_OUTPUT_CLASS` +- **WHEN** it is added without a Pi surface entry and without an exclusion-list entry +- **THEN** the parity guard test fails, naming the tool +- **AND** the failure is resolved only by surfacing it in Pi or adding it to the exclusion list + with a stated reason + +#### Scenario: A deliberate omission is recorded, not silent + +- **GIVEN** a conclusion tool that belongs only to an opt-in preset surface (e.g. federation) +- **WHEN** it is placed on the Pi exclusion list with its reason +- **THEN** the parity guard passes and the omission is auditable in source + +#### Scenario: Pi's verify_claim expresses every claim kind the handler supports + +- **GIVEN** the MCP `verify_claim` handler supporting the `decision-current` claim kind +- **WHEN** a Pi agent is about to cite a decision id to a human +- **THEN** the Pi `verify_claim` tool accepts kind `decision-current` and returns the daemon's + verdict (including `refuted` with the live superseder for a superseded decision), instead of the + kind being inexpressible on the Pi surface + +#### Scenario: A registered but inactive tool counts as surfaced + +- **GIVEN** a conclusion tool that the Pi extension registers inside a tool group +- **WHEN** the tool is inactive at session start +- **THEN** the parity guard treats the tool as present in the Pi surface +- **AND** the guard does not require an exclusion-list entry for it + +## ADDED Requirements + +### Requirement: PiDefaultToolSurfaceIsLean + +When a Pi session starts with the default configuration, the Pi extension SHALL activate only its +lean surface: the Pi tools that correspond to the MCP `substrate` preset, the configuration tool, +and the tool-group activator. All other OpenLore tools SHALL stay registered and inactive. The +extension SHALL NOT change the active state of any tool that OpenLore does not register. The lean +surface SHALL apply in every Pi session mode. + +#### Scenario: Default session exposes only the lean surface + +- **GIVEN** a repository whose `.openlore/config.json` has no `pi.toolSurface` key +- **WHEN** a Pi session starts +- **THEN** the active OpenLore tools are exactly the Pi tools of the `substrate` preset, the + configuration tool, and the activator +- **AND** every other OpenLore tool is registered but inactive + +#### Scenario: Host tools are not changed + +- **GIVEN** a Pi session in which the host has activated built-in and other extension tools +- **WHEN** the OpenLore extension applies its lean surface +- **THEN** the active state of every non-OpenLore tool is unchanged + +#### Scenario: Substrate preset and Pi lean surface stay aligned + +- **GIVEN** a change that adds a tool to, or removes a tool from, the MCP `substrate` preset +- **WHEN** the Pi lean surface does not match the Pi-registered tools of that preset +- **THEN** a CI test fails, naming the tools that differ + +### Requirement: PiToolGroupsAreActivatable + +Every OpenLore tool that the Pi extension registers outside the lean surface SHALL belong to exactly +one named tool group. The activator SHALL accept a list of names. Each name SHALL be a group name, or +the name of a tool (with or without the `openlore_` prefix), which activates the group that contains +it. The activator description SHALL list every group with the names of its tools. Activation SHALL be +additive and idempotent, and SHALL last until the session ends; a new session SHALL start with the +lean surface. The activator SHALL NOT activate a tool that was inactive when the session started for a +reason other than the lean surface (for example, a host tool allowlist). + +#### Scenario: Every non-lean tool has one group + +- **GIVEN** the set of tools the Pi extension registers +- **WHEN** the group-coverage test runs +- **THEN** it fails if a tool outside the lean surface belongs to no group or to more than one group + +#### Scenario: Activating a group + +- **GIVEN** a default Pi session +- **WHEN** the agent calls the activator with one valid group name +- **THEN** every tool in that group becomes active from the next model request +- **AND** the result names the tools that were activated + +#### Scenario: Activating by tool name + +- **GIVEN** a default Pi session in which a tool is inactive +- **WHEN** the agent calls the activator with that tool's name +- **THEN** the group that contains the tool becomes active + +#### Scenario: Repeated activation changes nothing + +- **GIVEN** a group that is already active +- **WHEN** the agent activates the same group again +- **THEN** the set of active tools does not change +- **AND** the result says that the group was already active + +#### Scenario: Unknown name + +- **WHEN** the agent calls the activator with a name that is neither a group nor a registered tool +- **THEN** no tool is activated +- **AND** the result is an error that lists the valid group names + +#### Scenario: Host exclusion is respected + +- **GIVEN** a Pi session in which the host allowlist excludes an OpenLore tool +- **WHEN** the agent activates the group that contains that tool +- **THEN** the excluded tool stays inactive +- **AND** the result names the tool as excluded by the host + +### Requirement: PiToolSurfaceHasAnEscape + +The `.openlore/config.json` key `pi.toolSurface` SHALL select the Pi tool surface. The value `"all"` +SHALL activate every OpenLore tool at session start and SHALL leave the activator inactive. The value +`"lean"`, an absent key, a malformed value, or an unreadable configuration SHALL select the default +lean surface. The key SHALL be read without requiring an LLM provider to be configured. + +#### Scenario: Operator keeps every tool active + +- **GIVEN** `.openlore/config.json` contains `"pi": { "toolSurface": "all" }` +- **WHEN** a Pi session starts +- **THEN** every OpenLore tool is active +- **AND** the activator is inactive + +#### Scenario: Malformed value falls back to lean + +- **GIVEN** `.openlore/config.json` contains `"pi": { "toolSurface": 3 }` +- **WHEN** a Pi session starts +- **THEN** the lean surface is active and the session starts without error + +#### Scenario: No provider configured + +- **GIVEN** a configuration with `pi.toolSurface` set to `"all"` and no LLM provider +- **WHEN** a Pi session starts +- **THEN** every OpenLore tool is active + +### Requirement: PiStandingContextIsBudgeted + +A CI test SHALL compute a deterministic estimate of the standing context that the OpenLore tools add +to the Pi prompt, for the lean surface and for the `"all"` surface. The estimate SHALL include each +active tool's parameter schema, prompt snippet, and guideline. Each surface SHALL have a reviewed +budget that records its measured baseline and its headroom; the test SHALL fail when an estimate +exceeds its budget. Each tool's prompt snippet SHALL be one line and SHALL NOT be identical to the +tool's full description. + +#### Scenario: Surface grows past its budget + +- **GIVEN** a change that adds tools or text to the Pi lean surface +- **WHEN** the lean estimate exceeds the lean budget +- **THEN** the budget test fails, naming the surface, the estimate, and the budget + +#### Scenario: Snippet duplicates the description + +- **WHEN** a Pi tool's prompt snippet equals its full description or contains a line break +- **THEN** the snippet test fails, naming the tool diff --git a/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/tasks.md b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/tasks.md new file mode 100644 index 00000000..ed4ae37a --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-pi-lean-tool-surface/tasks.md @@ -0,0 +1,35 @@ +## 1. Decision and groundwork + +- [x] 1.1 Record a decision (`openlore decisions record`, or the `record_decision` MCP tool) that Pi adopts a lean default surface with task-group activation, reversing the "presets are MCP-wire only" position; verify it appears in `openlore decisions list` as a draft +- [x] 1.2 Add `toolSurface?: 'lean' | 'all'` to the Pi config type in `src/types/index.ts` and verify `npm run typecheck` passes +- [x] 1.3 Add a direct reader for `pi.toolSurface` (the `piMaySpawnDaemon` pattern: any value other than `"all"` selects lean, unreadable config selects lean) and verify unit tests for absent, `"all"`, `"lean"`, malformed, and no-provider configs pass + +## 2. Short snippets and budget + +- [x] 2.1 Add a required one-line `snippet` to every `NAV_TOOLS` entry and use it as `promptSnippet`; verify a test fails when a snippet equals its description or contains a line break +- [x] 2.2 Add a deterministic standing-context estimator for the Pi `lean` and `all` surfaces (schema + snippet + guideline, chars/4) with reviewed budgets that record the measured baseline and headroom; verify the budget test passes and fails when a budget is lowered below the estimate + +## 3. Groups and lean surface + +- [x] 3.1 Add the exported group table (lean set + `specs`, `memory`, `review`, `quality`, `inspect`) per design D2; verify a coverage test fails when a registered non-lean tool is in no group or in two groups +- [x] 3.2 Add a test that derives the expected Pi lean set from the MCP `substrate` preset plus `configure` and `activate_tools`; verify it fails when one tool is removed from the lean set +- [x] 3.3 In `session_start`, snapshot active tools, record the host-excluded OpenLore tools, and apply the lean surface with `setActiveTools` without changing non-OpenLore tools; verify with the mocked `ExtensionAPI` that non-OpenLore active tools are unchanged and that only the lean OpenLore tools are active +- [x] 3.4 In `"all"` mode, keep every OpenLore tool active and deactivate only the activator; verify with a mocked-API test +- [x] 3.5 Confirm the parity guard still passes with registered but inactive tools; add the "registered but inactive counts as surfaced" test case + +## 4. Activator tool + +- [x] 4.1 Register `openlore_activate_tools({ names })` with a description that lists each group and its tools and a guideline that names the tool; verify a test asserts every group and every group tool appears in the description +- [x] 4.2 Implement name resolution (group name, tool name with or without `openlore_` prefix) with all-or-nothing validation; verify tests for a valid group, a tool name, an unknown name (error lists groups, nothing activated), and a repeated activation (no change, reported as already active) +- [x] 4.3 Skip host-excluded tools and report them in the result; verify with a mocked-API test in which one group tool was inactive at session start + +## 5. Docs and guards + +- [x] 5.1 Update the file header of `src/pi/extension.ts` and the Pi sections of `docs/install.md` and `docs/cli-reference.md` (lean default, groups, `pi.toolSurface`); verify the `QuantitativeDocClaimsAreGuarded` test passes and no doc states a hard-coded Pi tool count that is not tied to code +- [x] 5.2 Run `select_tests` for the diff and the full Pi test file (`vitest run src/pi`), plus `npm run typecheck` and lint; verify all pass + +## 6. Default and release + +- [x] 6.1 Ship `"lean"` as the default; verify a test asserts that an absent `pi.toolSurface` selects the lean surface +- [x] 6.2 Approve the decision from 1.1, citing ADR-0023 and parity with the Claude Code default; verify `openlore decisions list` shows it approved +- [x] 6.3 Add a release-notes entry naming the lean Pi default, the activator, and the `pi.toolSurface: "all"` escape; verify `openspec validate add-pi-lean-tool-surface --strict` passes diff --git a/openspec/decisions/adr-0034-pi-starts-with-the-substrate-tool-surface-and-on-d.md b/openspec/decisions/adr-0034-pi-starts-with-the-substrate-tool-surface-and-on-d.md new file mode 100644 index 00000000..c2cd7f8c --- /dev/null +++ b/openspec/decisions/adr-0034-pi-starts-with-the-substrate-tool-surface-and-on-d.md @@ -0,0 +1,24 @@ +# ADR-0034: Pi starts with the substrate tool surface and on-demand tool groups + +## Status + +accepted + +**Domains**: + +## Context + +Parity with the Claude Code default (ADR-0023): a Pi session activates only the substrate preset tools plus openlore_configure and openlore_activate_tools, which turns on task groups (specs, memory, review, quality, inspect) on demand. This reverses the earlier position that presets are an MCP-only concept Pi does not use, and cuts standing context for local models. pi.toolSurface: all keeps every tool active. + +## Decision + +Pi starts with the substrate tool surface and on-demand tool groups + +## Consequences + +Non-substrate Pi tools are registered but inactive until activated; each activation changes the tool list once (prompt cache miss). A test keeps the Pi lean set equal to TOOL_PRESETS.substrate. + +> Recorded by openlore decisions on 2026-09-15 +> Decision ID: 848b360d + + diff --git a/openspec/specs/mcp-quality/spec.md b/openspec/specs/mcp-quality/spec.md index d41dc975..0c0f0fad 100644 --- a/openspec/specs/mcp-quality/spec.md +++ b/openspec/specs/mcp-quality/spec.md @@ -571,12 +571,14 @@ candidate surface, a flip only on no-regression evidence, and a superseding reco The Pi extension's native tool surface SHALL be held in verified parity with the MCP tool surface in both directions: every Pi-surfaced tool SHALL be dispatchable by the daemon (the existing direction), AND every dispatchable conclusion tool SHALL either be present in the Pi surface or -appear on a named, source-commented exclusion list stating why its omission is deliberate. A CI -test SHALL fail when a conclusion tool is neither surfaced nor excluded, so a new MCP tool cannot -silently drift out of Pi — the same fails-until-you-decide discipline the tool-contract -classification test already enforces. Where a tool's input contract differs between surfaces -(e.g. an enum of claim kinds), the Pi declaration SHALL NOT silently omit capabilities the MCP -handler supports; a deliberate narrowing SHALL be stated in source. +appear on a named, source-commented exclusion list stating why its omission is deliberate. A tool +is present in the Pi surface when the extension registers it, whether the tool is active at session +start or becomes active through a tool group. A CI test SHALL fail when a conclusion tool is neither +surfaced nor excluded, so a new MCP tool cannot silently drift out of Pi — the same +fails-until-you-decide discipline the tool-contract classification test already enforces. Where a +tool's input contract differs between surfaces (e.g. an enum of claim kinds), the Pi declaration +SHALL NOT silently omit capabilities the MCP handler supports; a deliberate narrowing SHALL be +stated in source. #### Scenario: A new conclusion tool cannot skip the Pi decision @@ -600,6 +602,136 @@ handler supports; a deliberate narrowing SHALL be stated in source. verdict (including `refuted` with the live superseder for a superseded decision), instead of the kind being inexpressible on the Pi surface +#### Scenario: A registered but inactive tool counts as surfaced + +- **GIVEN** a conclusion tool that the Pi extension registers inside a tool group +- **WHEN** the tool is inactive at session start +- **THEN** the parity guard treats the tool as present in the Pi surface +- **AND** the guard does not require an exclusion-list entry for it + +### Requirement: PiDefaultToolSurfaceIsLean + +When a Pi session starts with the default configuration, the Pi extension SHALL activate only its +lean surface: the Pi tools that correspond to the MCP `substrate` preset, the configuration tool, +and the tool-group activator. All other OpenLore tools SHALL stay registered and inactive. The +extension SHALL NOT change the active state of any tool that OpenLore does not register. The lean +surface SHALL apply in every Pi session mode. + +#### Scenario: Default session exposes only the lean surface + +- **GIVEN** a repository whose `.openlore/config.json` has no `pi.toolSurface` key +- **WHEN** a Pi session starts +- **THEN** the active OpenLore tools are exactly the Pi tools of the `substrate` preset, the + configuration tool, and the activator +- **AND** every other OpenLore tool is registered but inactive + +#### Scenario: Host tools are not changed + +- **GIVEN** a Pi session in which the host has activated built-in and other extension tools +- **WHEN** the OpenLore extension applies its lean surface +- **THEN** the active state of every non-OpenLore tool is unchanged + +#### Scenario: Substrate preset and Pi lean surface stay aligned + +- **GIVEN** a change that adds a tool to, or removes a tool from, the MCP `substrate` preset +- **WHEN** the Pi lean surface does not match the Pi-registered tools of that preset +- **THEN** a CI test fails, naming the tools that differ + +### Requirement: PiToolGroupsAreActivatable + +Every OpenLore tool that the Pi extension registers outside the lean surface SHALL belong to exactly +one named tool group. The activator SHALL accept a list of names. Each name SHALL be a group name, or +the name of a tool (with or without the `openlore_` prefix), which activates the group that contains +it. The activator description SHALL list every group with the names of its tools. Activation SHALL be +additive and idempotent, and SHALL last until the session ends; a new session SHALL start with the +lean surface. The activator SHALL NOT activate a tool that was inactive when the session started for a +reason other than the lean surface (for example, a host tool allowlist). + +#### Scenario: Every non-lean tool has one group + +- **GIVEN** the set of tools the Pi extension registers +- **WHEN** the group-coverage test runs +- **THEN** it fails if a tool outside the lean surface belongs to no group or to more than one group + +#### Scenario: Activating a group + +- **GIVEN** a default Pi session +- **WHEN** the agent calls the activator with one valid group name +- **THEN** every tool in that group becomes active from the next model request +- **AND** the result names the tools that were activated + +#### Scenario: Activating by tool name + +- **GIVEN** a default Pi session in which a tool is inactive +- **WHEN** the agent calls the activator with that tool's name +- **THEN** the group that contains the tool becomes active + +#### Scenario: Repeated activation changes nothing + +- **GIVEN** a group that is already active +- **WHEN** the agent activates the same group again +- **THEN** the set of active tools does not change +- **AND** the result says that the group was already active + +#### Scenario: Unknown name + +- **WHEN** the agent calls the activator with a name that is neither a group nor a registered tool +- **THEN** no tool is activated +- **AND** the result is an error that lists the valid group names + +#### Scenario: Host exclusion is respected + +- **GIVEN** a Pi session in which the host allowlist excludes an OpenLore tool +- **WHEN** the agent activates the group that contains that tool +- **THEN** the excluded tool stays inactive +- **AND** the result names the tool as excluded by the host + +### Requirement: PiToolSurfaceHasAnEscape + +The `.openlore/config.json` key `pi.toolSurface` SHALL select the Pi tool surface. The value `"all"` +SHALL activate every OpenLore tool at session start and SHALL leave the activator inactive. The value +`"lean"`, an absent key, a malformed value, or an unreadable configuration SHALL select the default +lean surface. The key SHALL be read without requiring an LLM provider to be configured. + +#### Scenario: Operator keeps every tool active + +- **GIVEN** `.openlore/config.json` contains `"pi": { "toolSurface": "all" }` +- **WHEN** a Pi session starts +- **THEN** every OpenLore tool is active +- **AND** the activator is inactive + +#### Scenario: Malformed value falls back to lean + +- **GIVEN** `.openlore/config.json` contains `"pi": { "toolSurface": 3 }` +- **WHEN** a Pi session starts +- **THEN** the lean surface is active and the session starts without error + +#### Scenario: No provider configured + +- **GIVEN** a configuration with `pi.toolSurface` set to `"all"` and no LLM provider +- **WHEN** a Pi session starts +- **THEN** every OpenLore tool is active + +### Requirement: PiStandingContextIsBudgeted + +A CI test SHALL compute a deterministic estimate of the standing context that the OpenLore tools add +to the Pi prompt, for the lean surface and for the `"all"` surface. The estimate SHALL include each +active tool's parameter schema, prompt snippet, and guideline. Each surface SHALL have a reviewed +budget that records its measured baseline and its headroom; the test SHALL fail when an estimate +exceeds its budget. Each tool's prompt snippet SHALL be one line and SHALL NOT be identical to the +tool's full description. + +#### Scenario: Surface grows past its budget + +- **GIVEN** a change that adds tools or text to the Pi lean surface +- **WHEN** the lean estimate exceeds the lean budget +- **THEN** the budget test fails, naming the surface, the estimate, and the budget + +#### Scenario: Snippet duplicates the description + +- **WHEN** a Pi tool's prompt snippet equals its full description or contains a line break +- **THEN** the snippet test fails, naming the tool + ### Requirement: QuantitativeDocClaimsAreGuarded Every quantitative claim published in a user-facing surface (README, `docs/`, `CLAUDE.md`, diff --git a/src/core/services/config-schema.ts b/src/core/services/config-schema.ts index b690f256..95d87343 100644 --- a/src/core/services/config-schema.ts +++ b/src/core/services/config-schema.ts @@ -274,7 +274,7 @@ const workspaceRule: ConfigRule = { const piRule: ConfigRule = { kind: 'object', strict: true, - fields: fieldsFor({ spawnDaemon: booleanRule }), + fields: fieldsFor({ spawnDaemon: booleanRule, toolSurface: { kind: 'enum', values: ['lean', 'all'] } }), required: requiredFor({}), }; diff --git a/src/core/services/tls-coverage.test.ts b/src/core/services/tls-coverage.test.ts index 33e487e6..80464899 100644 --- a/src/core/services/tls-coverage.test.ts +++ b/src/core/services/tls-coverage.test.ts @@ -46,12 +46,12 @@ const EXEMPT: { file: string; line: number; why: string }[] = [ { file: 'src/cli/commands/serve.ts', line: 397, why: 'loopback http:// authenticated shutdown request' }, { file: 'src/cli/commands/serve-descriptor.ts', line: 224, why: 'loopback http:// legacy liveness probe' }, { file: 'src/api/health.ts', line: 111, why: 'loopback http:// watcher-state probe of an announced daemon' }, - { file: 'src/pi/extension.ts', line: 628, why: 'loopback http:// health probe' }, - { file: 'src/pi/extension.ts', line: 821, why: 'loopback http:// daemon call' }, - { file: 'src/pi/extension.ts', line: 1669, why: 'loopback http:// health probe' }, + { file: 'src/pi/extension.ts', line: 647, why: 'loopback http:// health probe' }, + { file: 'src/pi/extension.ts', line: 840, why: 'loopback http:// daemon call' }, + { file: 'src/pi/extension.ts', line: 1896, why: 'loopback http:// health probe' }, { file: 'src/pi/extension.ts', - line: 232, + line: 251, why: 'pre-existing: the Pi host never opts in, so skipSslVerify is not honoured there at all', }, ]; diff --git a/src/pi/extension.test.ts b/src/pi/extension.test.ts index adfe098e..68791130 100644 --- a/src/pi/extension.test.ts +++ b/src/pi/extension.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createServer } from 'node:http'; -import openloreExtension, { createPiExtension, modelsUrl, stripMarker, isUsableConfig, readConfig, loadExistingConfig, runConfigWizard, readSpecIndex, formatToolResult, formatCallArgs, compositeToolResult, NAV_TOOLS, PI_DAEMON_PRESET, PI_EXCLUDED_CONCLUSION_TOOLS, PI_SPEC_WORKFLOW_OBSERVATIONS, PI_SPEC_WORKFLOW_EXCLUSIONS, ensureDaemon, ensureDaemonResult, callTool, isUsableDaemon, missingDaemonTools, piDaemonSpawnCommand, PiDaemonConnectionError, PI_SPEC_INDEX_MAX_DOMAINS, shouldNegativeCacheDaemonFailure, piMaySpawnDaemon } from './extension.js'; +import openloreExtension, { createPiExtension, modelsUrl, stripMarker, isUsableConfig, readConfig, loadExistingConfig, runConfigWizard, readSpecIndex, formatToolResult, formatCallArgs, compositeToolResult, NAV_TOOLS, PI_DAEMON_PRESET, PI_EXCLUDED_CONCLUSION_TOOLS, PI_SPEC_WORKFLOW_OBSERVATIONS, PI_SPEC_WORKFLOW_EXCLUSIONS, ensureDaemon, ensureDaemonResult, callTool, isUsableDaemon, missingDaemonTools, piDaemonSpawnCommand, PiDaemonConnectionError, PI_SPEC_INDEX_MAX_DOMAINS, shouldNegativeCacheDaemonFailure, piMaySpawnDaemon, piToolSurface, PI_TOOL_SNIPPETS, PI_LEAN_TOOLS, PI_TOOL_GROUPS, PI_ACTIVATOR_TOOL, PI_STANDING_CONTEXT_BUDGETS, piRegisteredToolNames, estimatePiStandingTokens } from './extension.js'; import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; -import { TOOL_DEFINITIONS } from '../cli/commands/mcp.js'; +import { TOOL_DEFINITIONS, TOOL_PRESETS } from '../cli/commands/mcp.js'; import { startServe } from '../cli/commands/serve.js'; import { TOOL_OUTPUT_CLASS } from '../core/services/mcp-handlers/tool-contract.js'; import { pointerLineFor } from '../cli/commands/orient-inject-render.js'; @@ -1394,3 +1394,254 @@ describe('Pi config wizard — repo-configured endpoints', () => { }); }); +// change: add-pi-lean-tool-surface +describe('Pi lean tool surface', () => { + interface RegisteredTool { + name: string; + description: string; + promptSnippet?: string; + promptGuidelines?: string[]; + parameters: unknown; + execute: (...args: unknown[]) => Promise<{ content: Array<{ text: string }>; details: unknown }>; + } + + let dir: string; + beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'openlore-pi-surface-')); }); + afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); + + const prefixed = (names: readonly string[]) => names.map((name) => `openlore_${name}`); + const sorted = (names: Iterable) => [...names].sort(); + + // A host double with Pi's active-tool semantics: registered extension tools start active. + function registerPiSurface(initialActive?: (registered: string[]) => string[]) { + const handlers = new Map(); + const tools = new Map(); + let active: string[] = []; + const setActiveTools = vi.fn((names: string[]) => { active = [...names]; }); + const pi = { + registerTool: vi.fn((tool: RegisteredTool) => { tools.set(tool.name, tool); }), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: PiEventHandler) => { handlers.set(event, handler); }), + getActiveTools: () => [...active], + setActiveTools, + } as unknown as ExtensionAPI; + openloreExtension(pi); + const registered = [...tools.keys()]; + active = initialActive ? initialActive(registered) : ['read', 'bash', ...registered]; + const ctx = { + cwd: dir, + mode: 'json', + hasUI: false, + ui: { select: vi.fn(), input: vi.fn(), confirm: vi.fn(), notify: vi.fn() }, + } as unknown as ExtensionContext; + return { + tools, + setActiveTools, + active: () => active, + activeOpenlore: () => sorted(active.filter((name) => name.startsWith('openlore_'))), + startSession: async () => { await handlers.get('session_start')!({}, ctx); }, + activate: async (names: string[]) => tools.get(PI_ACTIVATOR_TOOL)!.execute('call', { names }, undefined, undefined, ctx), + }; + } + + async function writePiConfig(pi: unknown): Promise { + await mkdir(join(dir, '.openlore'), { recursive: true }); + await writeFile(join(dir, '.openlore', 'config.json'), JSON.stringify({ pi })); + } + + describe('pi.toolSurface config', () => { + it('selects lean when the config is absent', async () => { + expect(await piToolSurface(dir)).toBe('lean'); + }); + + it('selects all only for the exact string "all", without a provider', async () => { + await writePiConfig({ toolSurface: 'all' }); + expect(await piToolSurface(dir)).toBe('all'); + }); + + it('selects lean for "lean", malformed values, and a null pi block', async () => { + for (const pi of [{ toolSurface: 'lean' }, { toolSurface: 3 }, { toolSurface: 'ALL' }, null]) { + await writePiConfig(pi); + expect(await piToolSurface(dir), JSON.stringify(pi)).toBe('lean'); + } + }); + + it('selects lean when the config is not valid JSON', async () => { + await mkdir(join(dir, '.openlore'), { recursive: true }); + await writeFile(join(dir, '.openlore', 'config.json'), '{ not json'); + expect(await piToolSurface(dir)).toBe('lean'); + }); + }); + + describe('groups', () => { + it('derives the lean set from the MCP substrate preset plus the Pi utilities', () => { + const expected = sorted([...TOOL_PRESETS.substrate, 'configure', 'activate_tools']); + expect(sorted(PI_LEAN_TOOLS)).toEqual(expected); + // The guard fails when the Pi lean set loses a substrate tool. + expect(sorted(PI_LEAN_TOOLS.filter((name) => name !== 'recall'))).not.toEqual(expected); + }); + + it('registers every lean tool', () => { + const registered = new Set(piRegisteredToolNames()); + const missing = prefixed(PI_LEAN_TOOLS).filter((name) => !registered.has(name)); + expect(missing, `lean tools not registered: ${missing.join(', ')}`).toEqual([]); + }); + + it('puts every registered non-lean tool in exactly one group', () => { + const lean = new Set(prefixed(PI_LEAN_TOOLS)); + const memberships = new Map(); + for (const [group, names] of Object.entries(PI_TOOL_GROUPS)) { + for (const name of prefixed(names)) memberships.set(name, [...(memberships.get(name) ?? []), group]); + } + const problems = piRegisteredToolNames() + .filter((name) => !lean.has(name)) + .map((name) => [name, memberships.get(name) ?? []] as const) + .filter(([, groups]) => groups.length !== 1) + .map(([name, groups]) => `${name} → [${groups.join(', ')}]`); + expect(problems, `each non-lean tool needs exactly one group: ${problems.join('; ')}`).toEqual([]); + + const registered = new Set(piRegisteredToolNames()); + const stale = [...memberships.keys()].filter((name) => !registered.has(name) || lean.has(name)); + expect(stale, `group entries that are unregistered or lean: ${stale.join(', ')}`).toEqual([]); + }); + + it('counts a registered but inactive tool as surfaced for the parity guard', () => { + // The parity guard's surfaced set is NAV_TOOLS, which is registration, not activation. + const surfaced = new Set(NAV_TOOLS.map((tool) => tool.name)); + for (const names of Object.values(PI_TOOL_GROUPS)) { + for (const name of names) expect(surfaced.has(name), `${name} is grouped but not registered`).toBe(true); + } + }); + }); + + describe('session start', () => { + it('activates only the lean surface by default and keeps host tools unchanged', async () => { + const surface = registerPiSurface((registered) => ['read', ...registered]); + await surface.startSession(); + expect(surface.activeOpenlore()).toEqual(sorted(prefixed(PI_LEAN_TOOLS))); + expect(surface.active()).toContain('read'); + expect(surface.active()).not.toContain('bash'); + }); + + it('keeps every tool active except the activator for "all", with no provider configured', async () => { + await writePiConfig({ toolSurface: 'all' }); + const surface = registerPiSurface(); + await surface.startSession(); + expect(surface.activeOpenlore()).toEqual(sorted(piRegisteredToolNames().filter((name) => name !== PI_ACTIVATOR_TOOL))); + }); + + it('falls back to the lean surface for a malformed value', async () => { + await writePiConfig({ toolSurface: 3 }); + const surface = registerPiSurface(); + await surface.startSession(); + expect(surface.activeOpenlore()).toEqual(sorted(prefixed(PI_LEAN_TOOLS))); + }); + + it('starts a new session lean without treating its own suppression as a host exclusion', async () => { + const surface = registerPiSurface(); + await surface.startSession(); + await surface.activate(['specs']); + await surface.startSession(); + expect(surface.activeOpenlore()).toEqual(sorted(prefixed(PI_LEAN_TOOLS))); + await surface.activate(['specs']); + expect(surface.active()).toEqual(expect.arrayContaining(prefixed(PI_TOOL_GROUPS.specs))); + }); + + it('leaves every tool active on a host without active-tool support', async () => { + const handlers = registerPiHandlers(); + await expect(handlers.get('session_start')!({}, { + cwd: dir, mode: 'json', hasUI: false, ui: { notify: vi.fn() }, + } as unknown as ExtensionContext)).resolves.toBeUndefined(); + }); + }); + + describe('activator', () => { + it('lists every group and every group tool in its description', () => { + const description = registerPiSurface().tools.get(PI_ACTIVATOR_TOOL)!.description; + for (const [group, names] of Object.entries(PI_TOOL_GROUPS)) { + expect(description).toContain(`${group}:`); + for (const name of prefixed(names)) expect(description, `${name} missing`).toContain(name); + } + }); + + it('activates a group and names the activated tools', async () => { + const surface = registerPiSurface(); + await surface.startSession(); + const result = await surface.activate(['specs']); + expect(surface.active()).toEqual(expect.arrayContaining(prefixed(PI_TOOL_GROUPS.specs))); + expect(result.details).toMatchObject({ activated: prefixed(PI_TOOL_GROUPS.specs), alreadyActive: [] }); + expect(surface.activeOpenlore()).not.toContain('openlore_remember'); + }); + + it('activates the owning group from a tool name, with or without the prefix', async () => { + const surface = registerPiSurface(); + await surface.startSession(); + await surface.activate(['check_spec_drift', 'openlore_remember']); + expect(surface.active()).toEqual(expect.arrayContaining([ + ...prefixed(PI_TOOL_GROUPS.specs), ...prefixed(PI_TOOL_GROUPS.memory), + ])); + }); + + it('rejects an unknown name, lists the groups, and activates nothing', async () => { + const surface = registerPiSurface(); + await surface.startSession(); + const calls = surface.setActiveTools.mock.calls.length; + const before = surface.active(); + const result = await surface.activate(['specs', 'not_a_tool']); + expect(result.content[0].text).toContain('not_a_tool'); + expect(result.content[0].text).toContain(Object.keys(PI_TOOL_GROUPS).join(', ')); + expect(surface.setActiveTools.mock.calls.length).toBe(calls); + expect(surface.active()).toEqual(before); + }); + + it('reports a repeated activation as already active and changes nothing', async () => { + const surface = registerPiSurface(); + await surface.startSession(); + await surface.activate(['review']); + const before = surface.active(); + const result = await surface.activate(['review']); + expect(result.details).toMatchObject({ activated: [], alreadyActive: ['review'] }); + expect(surface.active()).toEqual(before); + }); + + it('never activates a tool the host had turned off, and names it', async () => { + const surface = registerPiSurface((registered) => registered.filter((name) => name !== 'openlore_get_spec')); + await surface.startSession(); + const result = await surface.activate(['specs']); + expect(surface.active()).not.toContain('openlore_get_spec'); + expect(surface.active()).toContain('openlore_search_specs'); + expect(result.details).toMatchObject({ hostExcluded: ['openlore_get_spec'] }); + }); + }); + + describe('standing context', () => { + it('gives every registered tool a one-line snippet that is not its description', () => { + const snippetProblem = (tool: { name: string; description: string; promptSnippet?: string }) => + !tool.promptSnippet || /\n/.test(tool.promptSnippet) || tool.promptSnippet === tool.description || tool.promptSnippet.length > 90; + const problems = [...registerPiSurface().tools.values()].filter(snippetProblem).map((tool) => tool.name); + expect(problems, `bad snippets: ${problems.join(', ')}`).toEqual([]); + // The check flags a snippet that repeats the description. + expect(snippetProblem({ name: 'x', description: 'Same.', promptSnippet: 'Same.' })).toBe(true); + }); + + it('has a snippet for every NAV tool and no stale snippet entries', () => { + const names = new Set(NAV_TOOLS.map((tool) => tool.name)); + expect(sorted(Object.keys(PI_TOOL_SNIPPETS))).toEqual(sorted(names)); + }); + + it('keeps each surface within its reviewed budget', () => { + const tools = [...registerPiSurface().tools.values()]; + const lean = new Set(prefixed(PI_LEAN_TOOLS)); + const estimates = { + lean: estimatePiStandingTokens(tools.filter((tool) => lean.has(tool.name))), + all: estimatePiStandingTokens(tools.filter((tool) => tool.name !== PI_ACTIVATOR_TOOL)), + }; + for (const [surface, estimate] of Object.entries(estimates)) { + const budget = PI_STANDING_CONTEXT_BUDGETS[surface as keyof typeof PI_STANDING_CONTEXT_BUDGETS]; + expect(estimate, `${surface} surface: estimate ${estimate} exceeds budget ${budget.maxTokens}`).toBeLessThanOrEqual(budget.maxTokens); + expect(budget.maxTokens).toBeGreaterThanOrEqual(budget.baselineTokens); + } + expect(estimates.lean).toBeLessThan(estimates.all); + }); + }); +}); diff --git a/src/pi/extension.ts b/src/pi/extension.ts index 5013618d..17bc1fb4 100644 --- a/src/pi/extension.ts +++ b/src/pi/extension.ts @@ -9,10 +9,12 @@ * architecture digest + spec index + task-grounded orient call, so weak * tool-callers benefit even without calling a tool. * B — native tools (registerTool): the substrate surface for on-demand structural - * queries — NAV_TOOLS spans navigate + change + remember + verify + governance - * (it already supersets the MCP `substrate` preset; the family taxonomy and the - * preset/breadth selectors are MCP-wire concepts the native Pi host does not use), - * each round-tripping to the warm daemon via fetch. + * queries — NAV_TOOLS spans navigate + change + remember + verify + governance, + * each round-tripping to the warm daemon via fetch. Every tool is registered, but a + * session starts with only the lean set active (the MCP `substrate` preset, the same + * default Claude Code gets); openlore_activate_tools turns on the task groups in + * PI_TOOL_GROUPS. `pi.toolSurface: "all"` keeps every tool active. + * (change: add-pi-lean-tool-surface) * * Uses ctx.mode (0.78.1+): full injection in tui/rpc (interactive), none in * json/print (one-shot). rpc = headless interactive over stdin/stdout (IDE, @@ -179,6 +181,23 @@ export async function piMaySpawnDaemon(cwd: string): Promise { } } +export type PiToolSurface = 'lean' | 'all'; + +/** + * Which OpenLore tools a Pi session starts with. Read like `piMaySpawnDaemon`, so it works before + * a provider is configured. Only the exact string `"all"` widens the surface; an absent, + * malformed, or unreadable value keeps the lean default. + */ +export async function piToolSurface(cwd: string): Promise { + try { + const raw = JSON.parse(await readFile(safeJoin(cwd, join(OPENLORE_DIR, 'config.json')), 'utf-8')) as unknown; + const pi = raw && typeof raw === 'object' ? (raw as { pi?: { toolSurface?: unknown } | null }).pi : undefined; + return pi?.toolSurface === 'all' ? 'all' : 'lean'; + } catch { + return 'lean'; + } +} + async function writeConfig(cwd: string, config: OpenLoreConfig): Promise { const configPath = safeJoin(cwd, join(OPENLORE_DIR, 'config.json')); await mkdir(safeJoin(cwd, OPENLORE_DIR), { recursive: true }); @@ -1335,6 +1354,191 @@ export const NAV_TOOLS: NavToolSpec[] = [ }, ]; +// One-line entries for Pi's "Available tools" prompt section. The full description +// already reaches the model with the tool schema; repeating it here doubled the +// standing context (spec: PiStandingContextIsBudgeted). +export const PI_TOOL_SNIPPETS: Record = { + orient: 'Start any new task here: relevant functions, files, specs, and where to add code.', + search_code: 'Find code by concept or name, instead of grepping.', + get_subgraph: 'Callers and callees of one function.', + trace_execution_path: 'Every call path from one function to another.', + find_path: 'Cheapest call route from A to B.', + analyze_impact: 'Everything that depends on a function or type.', + select_tests: 'Tests to run for given functions or your uncommitted changes.', + get_test_coverage: 'Which code has tests and which does not.', + blast_radius: 'Pre-commit briefing: callers, tests to run, specs and decisions at risk.', + structural_diff: 'Functions, edges, and signatures changed between two states, plus stale callers.', + verify_claim: 'Check a structural fact or a decision citation before you state it.', + suggest_insertion_points: 'Ranked places to add new code.', + get_function_skeleton: 'Signatures and control flow of a file, without bodies.', + get_health_map: 'Riskiest code areas, ranked.', + get_surprising_connections: 'Unexpected coupling between code regions.', + get_architecture_overview: 'Domain clusters, dependencies, entry points, and hubs.', + get_map: 'Region map of the codebase and how the regions connect.', + get_landmarks: 'Hubs, chokepoints, entry points, and dead code, with evidence.', + get_refactor_report: 'Refactoring targets ranked by priority.', + get_critical_hubs: 'Most-called functions, the riskiest to change.', + get_god_functions: 'Functions that call too many things.', + find_clones: 'Existing near-duplicates of code you are about to write.', + analyze_error_propagation: 'Errors that can escape a function, and the ones it handles.', + analyze_env_impact: 'Read sites and blast radius of an environment variable.', + certify_public_surface: 'Breaking-change check for exported code.', + get_style_fingerprint: 'House coding style to match in your edit.', + briefing_since: 'Changes since a ref that matter structurally, ranked.', + search_specs: 'Find spec requirements by meaning.', + search_unified: 'Search code and specs in one call.', + get_spec: 'Full spec for one domain.', + get_function_body: 'Source of one function, or a slice on one variable or callee.', + get_file_dependencies: 'Imports of a file and the files that import it.', + remember: 'Save a code-anchored fact for later sessions.', + recall: 'Notes from earlier sessions about the code you touch.', + check_spec_drift: 'Changed code that no longer matches its spec.', + audit_spec_coverage: 'Code with no spec, and stale or orphan requirements.', + list_spec_domains: 'Names of the spec domains.', + record_decision: 'Record a design decision before you write the code.', + list_decisions: 'Recorded decisions and their status.', + approve_decision: 'Approve a decision, only after the user says yes.', + reject_decision: 'Reject a pending decision.', + sync_decisions: 'Write approved decisions into the specs.', +}; + +// Tools active when a Pi session starts (names without the `openlore_` prefix): +// the MCP `substrate` preset — the default `openlore install` wires for Claude +// Code — plus the two Pi-only utilities. extension.test.ts derives this set from +// TOOL_PRESETS.substrate so the two surfaces cannot drift. +export const PI_LEAN_TOOLS: readonly string[] = [ + 'orient', 'search_code', 'get_subgraph', 'trace_execution_path', + 'analyze_impact', 'suggest_insertion_points', 'get_function_skeleton', + 'get_landmarks', 'get_map', 'find_path', + 'recall', 'verify_claim', 'blast_radius', + 'prepare_spec_generation', 'prepare_spec_repair', + 'configure', 'activate_tools', +]; + +// Task groups openlore_activate_tools turns on. Every registered tool outside +// PI_LEAN_TOOLS belongs to exactly one group (guarded in extension.test.ts). +// Groups are task-shaped, not capability families: `navigate` alone holds most tools. +export const PI_TOOL_GROUPS: Record = { + specs: ['search_specs', 'get_spec', 'list_spec_domains', 'search_unified', 'check_spec_drift', 'audit_spec_coverage'], + memory: ['remember', 'record_decision', 'list_decisions', 'approve_decision', 'reject_decision', 'sync_decisions'], + review: ['structural_diff', 'select_tests', 'get_test_coverage', 'briefing_since', 'certify_public_surface'], + quality: [ + 'get_refactor_report', 'get_health_map', 'get_critical_hubs', 'get_god_functions', + 'get_architecture_overview', 'get_surprising_connections', 'find_clones', 'get_style_fingerprint', + ], + inspect: ['get_function_body', 'get_file_dependencies', 'analyze_error_propagation', 'analyze_env_impact'], +}; + +const PI_TOOL_PREFIX = 'openlore_'; +export const PI_ACTIVATOR_TOOL = `${PI_TOOL_PREFIX}activate_tools`; + +/** Every tool name the extension registers, with the `openlore_` prefix. */ +export function piRegisteredToolNames(): string[] { + return [ + ...NAV_TOOLS.map((tool) => tool.name), + 'prepare_spec_generation', 'prepare_spec_repair', 'configure', 'activate_tools', + ].map((name) => PI_TOOL_PREFIX + name); +} + +/** + * The active set a session starts with. Non-OpenLore tools keep their state; OpenLore + * tools the host had already turned off (`hostExcluded`) stay off. + */ +export function piSessionActiveTools( + surface: PiToolSurface, + active: readonly string[], + hostExcluded: ReadonlySet, +): string[] { + const registered = new Set(piRegisteredToolNames()); + const wanted = surface === 'all' + ? [...registered].filter((name) => name !== PI_ACTIVATOR_TOOL) + : PI_LEAN_TOOLS.map((name) => PI_TOOL_PREFIX + name); + return [ + ...active.filter((name) => !registered.has(name)), + ...wanted.filter((name) => !hostExcluded.has(name)), + ]; +} + +export type PiActivationPlan = + | { ok: false; error: string } + | { ok: true; nextActive: string[]; activated: string[]; alreadyActive: string[]; hostExcluded: string[] }; + +/** + * Resolve activator names (a group, or a tool with or without the `openlore_` prefix) into the + * next active set. All-or-nothing: one unknown name activates nothing. A lean tool name is + * valid and changes nothing, because it is already on. + */ +export function planPiToolActivation( + names: readonly string[], + active: readonly string[], + hostExcluded: ReadonlySet, +): PiActivationPlan { + const groups = new Set(); + const unknown: string[] = []; + for (const raw of names) { + const name = raw.trim().replace(/^openlore_/, ''); + if (name in PI_TOOL_GROUPS) { groups.add(name); continue; } + const owner = Object.keys(PI_TOOL_GROUPS).find((group) => PI_TOOL_GROUPS[group].includes(name)); + if (owner) groups.add(owner); + else if (!PI_LEAN_TOOLS.includes(name)) unknown.push(raw); + } + if (unknown.length > 0) { + return { + ok: false, + error: `Unknown openlore tool group or tool: ${unknown.join(', ')}. Nothing was activated. Valid groups: ${Object.keys(PI_TOOL_GROUPS).join(', ')}.`, + }; + } + const current = new Set(active); + const activated: string[] = []; + const alreadyActive: string[] = []; + const excluded: string[] = []; + for (const group of groups) { + const tools = PI_TOOL_GROUPS[group].map((name) => PI_TOOL_PREFIX + name); + const blocked = tools.filter((name) => hostExcluded.has(name)); + const added = tools.filter((name) => !hostExcluded.has(name) && !current.has(name)); + excluded.push(...blocked); + if (added.length === 0) alreadyActive.push(group); + for (const name of added) { current.add(name); activated.push(name); } + } + return { ok: true, nextActive: [...current], activated, alreadyActive, hostExcluded: excluded }; +} + +/** The activator description: every group with the tools it turns on. */ +export function piActivatorDescription(): string { + const lines = Object.entries(PI_TOOL_GROUPS) + .map(([group, tools]) => `- ${group}: ${tools.map((name) => PI_TOOL_PREFIX + name).join(', ')}`); + return [ + 'Turn on more openlore tools for the rest of this session. Pass group names, or the name of any tool below.', + ...lines, + ].join('\n'); +} + +/** + * Reviewed ceilings for the standing context OpenLore tools add to the Pi prompt, estimated by + * `estimatePiStandingTokens`. Each entry records its measured baseline and bounded headroom, so + * raising a budget changes both a number and its rationale (the STANDING_CONTEXT_BUDGETS pattern). + */ +export const PI_STANDING_CONTEXT_BUDGETS: Record = { + lean: { baselineTokens: 3_089, maxTokens: 3_300, rationale: 'Measured baseline plus 6.8% headroom.' }, + all: { baselineTokens: 7_445, maxTokens: 7_950, rationale: 'Measured baseline plus 6.8% headroom.' }, +}; + +/** Deterministic estimate (characters / 4) of what the given registered tools add to the prompt. */ +export function estimatePiStandingTokens(tools: ReadonlyArray<{ + description: string; + promptSnippet?: string; + promptGuidelines?: readonly string[]; + parameters: unknown; +}>): number { + let chars = 0; + for (const tool of tools) { + chars += tool.description.length + (tool.promptSnippet?.length ?? 0); + chars += (tool.promptGuidelines ?? []).reduce((sum, line) => sum + line.length, 0); + chars += JSON.stringify(tool.parameters).length; + } + return Math.ceil(chars / 4); +} + // Conclusion tools deliberately NOT surfaced natively in Pi, each with a stated // reason (project doctrine: "if parity is intentionally skipped, say why"). // The two-direction parity guard (extension.test.ts) requires every dispatchable @@ -1626,6 +1830,29 @@ function registerOpenlore( const failedUntil = new Map(); const DAEMON_RETRY_COOLDOWN_MS = 30_000; const primed = new Set(); + // Tool-surface state for the current session (spec: PiToolGroupsAreActivatable). + // hostExcluded: OpenLore tools the host had turned off before the surface was applied. + // suppressed: tools this extension turned off, so a later session_start does not + // mistake them for host exclusions. + let hostExcluded = new Set(); + let suppressed = new Set(); + const hostSupportsActiveTools = (): boolean => + typeof pi.getActiveTools === 'function' && typeof pi.setActiveTools === 'function'; + async function applyToolSurface(cwd: string): Promise { + if (!hostSupportsActiveTools()) return; // older host: every tool stays active + try { + const active = pi.getActiveTools(); + const activeSet = new Set(active); + const registered = piRegisteredToolNames(); + hostExcluded = new Set(registered.filter((name) => !activeSet.has(name) && !suppressed.has(name))); + const next = piSessionActiveTools(await piToolSurface(cwd), active, hostExcluded); + const nextSet = new Set(next); + suppressed = new Set(registered.filter((name) => !nextSet.has(name) && !hostExcluded.has(name))); + pi.setActiveTools(next); + } catch { + // The host refused the change: every tool stays active, onboarding continues. + } + } async function getDaemon(cwd: string): Promise { const cached = daemons.get(cwd); if (cached) return cached; @@ -1680,7 +1907,7 @@ function registerOpenlore( name: `openlore_${tool.name}`, label: tool.label, description: tool.description, - promptSnippet: tool.description, + promptSnippet: PI_TOOL_SNIPPETS[tool.name], promptGuidelines: [tool.guideline], parameters: tool.parameters as TSchema, async execute(_id, params, signal, _onUpdate, ctx) { @@ -1802,6 +2029,38 @@ function registerOpenlore( }, }); + // ── Tool-group activator ── + pi.registerTool({ + name: PI_ACTIVATOR_TOOL, + label: 'openlore activate tools', + description: piActivatorDescription(), + promptSnippet: 'Turn on more openlore tools by group or by tool name.', + promptGuidelines: [ + 'When you need an openlore tool that is not in your tool list (including one named in an openlore result), call openlore_activate_tools with its group or tool name first.', + ], + parameters: Type.Object({ + names: Type.Array(Type.String(), { + minItems: 1, + description: `REQUIRED. Group names (${Object.keys(PI_TOOL_GROUPS).join(', ')}) or tool names, e.g. ["specs"] or ["check_spec_drift"].`, + }), + }), + async execute(_id, params) { + if (!hostSupportsActiveTools()) return toolResult('Every openlore tool is already active in this Pi version.'); + const { names } = params as { names: string[] }; + const plan = planPiToolActivation(names, pi.getActiveTools(), hostExcluded); + if (!plan.ok) return toolResult(plan.error, plan); + if (plan.activated.length > 0) pi.setActiveTools(plan.nextActive); + for (const name of plan.activated) suppressed.delete(name); + const summary = { + activated: plan.activated, + alreadyActive: plan.alreadyActive, + hostExcluded: plan.hostExcluded, + note: plan.activated.length > 0 ? 'Activated tools are available from your next step.' : 'No tool state changed.', + }; + return toolResult(JSON.stringify(summary, null, 2), summary); + }, + }); + // ── /configure slash command ── pi.registerCommand('openlore', { description: 'Open the openlore configuration wizard', @@ -1819,8 +2078,11 @@ function registerOpenlore( }, }); - // ── session_start: onboarding + daemon warmup ── + // ── session_start: tool surface + onboarding + daemon warmup ── pi.on('session_start', async (_event: SessionStartEvent, ctx: ExtensionContext) => { + // Lean tool surface first, in every mode (spec: PiDefaultToolSurfaceIsLean). + await applyToolSurface(ctx.cwd); + if (ctx.hasUI) { const loaded = await loadExistingConfig(ctx.cwd); if (loaded.state === 'absent') { diff --git a/src/types/index.ts b/src/types/index.ts index cf90d3ea..90a33217 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -130,6 +130,13 @@ export interface PiConfig { * unaffected. The `OPENLORE_PI_NO_SPAWN` environment variable overrides this. */ spawnDaemon?: boolean; + /** + * Which OpenLore tools are active when a Pi session starts. Default `'lean'`: the tools of the + * MCP `substrate` preset plus `openlore_configure` and `openlore_activate_tools`, which turns on + * the other tool groups on demand. `'all'` activates every OpenLore tool at session start + * (change: add-pi-lean-tool-surface). + */ + toolSurface?: 'lean' | 'all'; } export interface WorkspaceShardConfig { From 94294b5ba773f357083efdd2252b7bfe903d1b83 Mon Sep 17 00:00:00 2001 From: Laurent FRANCOISE Date: Tue, 15 Sep 2026 23:01:03 +0200 Subject: [PATCH 2/3] feat(decisions): add `openlore decisions record` CLI command Recording a decision needed the record_decision MCP tool, which the default `substrate` preset does not expose, so collaborators on default settings were blocked by the decisions gate. The new command calls the same handler, so ids, scope inference, anchors, the already-decided verdict, and background consolidation match the MCP tool on any preset. - validates --title, --rationale, --scope, and --constraints-file before writing - --json prints the handler result on stdout - gate and rejection hints named a non-existent `decisions --record`; they now name the real command, and a test keeps it that way - generated guidance names the CLI command when record_decision is not wired Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +- .../.openspec.yaml | 2 + .../proposal.md | 36 ++++ .../specs/cli/spec.md | 58 ++++++ .../tasks.md | 15 ++ openspec/specs/cli/spec.md | 57 ++++++ src/cli/commands/decisions-record.test.ts | 173 ++++++++++++++++++ src/cli/commands/decisions.ts | 91 ++++++++- src/core/analyzer/ai-config-generator.ts | 15 +- .../guidance-preset-coherence.test.ts | 2 + 10 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/archive/2026-09-15-add-decisions-record-cli/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-15-add-decisions-record-cli/proposal.md create mode 100644 openspec/changes/archive/2026-09-15-add-decisions-record-cli/specs/cli/spec.md create mode 100644 openspec/changes/archive/2026-09-15-add-decisions-record-cli/tasks.md create mode 100644 src/cli/commands/decisions-record.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 769b3832..35d2ebe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,9 @@ For all other cases (reading a file, grepping, listing files) use native tools d > both tiers; decision c79ec7ca / ADR-0023, superseding ADR-0022). Narrower/wider is opt-in: the lean > navigate-only `navigation` preset (10 tools, the one-flag escape), `--minimal` (governance core), > `--preset memory` / `verify` / `federation` / `coordination`, or the full surface via `--preset full` -> (`--all-tools`). The decisions-gate workflow below needs `record_decision`, which is **not** in the -> default — install with `--preset full` (or `--minimal`) on repos that gate commits. +> (`--all-tools`). The `record_decision` MCP tool is **not** in the default; on any preset, record a +> decision for the commit gate with `openlore decisions record --title "…" --rationale "…"`, or +> install with `--preset full` (or `--minimal`) to expose the MCP tool. > **OpenLore is one substrate with two faces (change `unify-navigation-and-governance-substrate`).** > Navigation (read the graph) and governance/memory (anchor facts, weigh changes) share one graph, one diff --git a/openspec/changes/archive/2026-09-15-add-decisions-record-cli/.openspec.yaml b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/.openspec.yaml new file mode 100644 index 00000000..96db9a43 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/archive/2026-09-15-add-decisions-record-cli/proposal.md b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/proposal.md new file mode 100644 index 00000000..85a4a0dc --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/proposal.md @@ -0,0 +1,36 @@ +## Why + +A decision can be recorded only through the `record_decision` MCP tool. The default MCP surface +(`substrate`, ADR-0023) does not include that tool, so a collaborator who installs OpenLore with +default settings cannot record a decision, and the pre-commit decisions gate blocks them. The CLI +already tells users to run `openlore decisions --record`, but that option does not exist. + +## What Changes + +- Add `openlore decisions record`, which records a draft decision with the same behavior as the + `record_decision` MCP tool: the same id, the same "already decided" verdict, anchors, and + background consolidation. Options: `--title`, `--rationale`, `--consequences`, `--files`, + `--supersedes`, `--scope`, `--constraints-file`, `--json`. +- Replace the two messages that name the non-existent `openlore decisions --record` with the new + command. +- When `record_decision` is not in the wired preset, the generated agent guidance names the CLI + command as the way to record a decision, instead of saying that the workflow is unavailable. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli`: a new requirement for recording decisions from the CLI, and for the gate and guidance + messages that point to it. + +## Impact + +- `src/cli/commands/decisions.ts`: new `record` subcommand; two message fixes. +- `src/core/analyzer/ai-config-generator.ts`: fallback guidance text. +- Tests: new CLI tests; `guidance-preset-coherence.test.ts` update. +- Docs: `docs/cli-reference.md` decisions section; `CHANGELOG.md`. +- No MCP change: `record_decision` keeps its contract; the CLI calls the same handler. diff --git a/openspec/changes/archive/2026-09-15-add-decisions-record-cli/specs/cli/spec.md b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/specs/cli/spec.md new file mode 100644 index 00000000..19e91f81 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/specs/cli/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: DecisionsCanBeRecordedFromTheCli + +`openlore decisions record` SHALL record a draft architectural decision for the repository in the +current directory, with the same behavior as the `record_decision` MCP tool: the same decision id, +the same scope inference, the same anchors, the same "already decided" verdict for a decision that +consolidation has decided, and the same background consolidation. The command SHALL accept +`--title` and `--rationale` (both required), and `--consequences`, `--files` (comma-separated +paths), `--supersedes`, `--scope` (`local`, `component`, `cross-domain`, or `system`), +`--constraints-file` (a JSON constraint block), and `--json`. The command SHALL work whatever MCP +preset is wired. When the input is not valid or the handler returns an error, the command SHALL +write no draft and SHALL exit with a non-zero code. + +Every CLI message that tells the user how to record a decision SHALL name +`openlore decisions record`. When `record_decision` is not part of the wired preset, the generated +agent guidance SHALL name `openlore decisions record` as the way to record a decision. + +#### Scenario: Record a draft with default settings + +- **GIVEN** a repository wired with the default MCP preset +- **WHEN** the user runs `openlore decisions record --title "Use UUIDs" --rationale "Collision-free ids"` +- **THEN** a draft decision is stored with that title and rationale +- **AND** the output names the draft id and the command that reads its verdict + +#### Scenario: JSON output matches the MCP result + +- **WHEN** the user runs the command with `--json` +- **THEN** stdout is one JSON object with the same fields the `record_decision` tool returns + +#### Scenario: Missing rationale + +- **WHEN** the user runs the command without `--rationale` +- **THEN** no draft is stored +- **AND** the command exits with a non-zero code and names the missing option + +#### Scenario: Invalid scope or constraint file + +- **WHEN** the user passes `--scope wide`, or a `--constraints-file` that is not valid JSON +- **THEN** no draft is stored and the command exits with a non-zero code + +#### Scenario: Re-recording a decided decision + +- **GIVEN** a decision that consolidation has already promoted, merged, or rejected +- **WHEN** the user records the same decision again +- **THEN** the output reports that verdict and no new draft is created + +#### Scenario: Gate message names the command + +- **WHEN** the decisions gate blocks a commit for an undocumented change +- **THEN** its message names `openlore decisions record` +- **AND** no CLI message names `openlore decisions --record` + +#### Scenario: Guidance without the MCP tool + +- **GIVEN** a repository whose wired preset does not include `record_decision` +- **WHEN** agent guidance is generated +- **THEN** the decisions section names `openlore decisions record` diff --git a/openspec/changes/archive/2026-09-15-add-decisions-record-cli/tasks.md b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/tasks.md new file mode 100644 index 00000000..cdce5bf5 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-decisions-record-cli/tasks.md @@ -0,0 +1,15 @@ +## 1. Command + +- [x] 1.1 Add the `decisions record` subcommand that parses the options and calls the `record_decision` handler for the current directory; verify with a CLI test that a draft is stored and the output names the id and `openlore decisions status ` +- [x] 1.2 Validate input before the handler (required `--title`/`--rationale`, `--scope` enum, `--constraints-file` readable JSON object) and exit non-zero on a handler `error`; verify tests for missing rationale, bad scope, and bad JSON store no draft and set a non-zero exit code +- [x] 1.3 Support `--json` (result on stdout, logs on stderr) and forward `--files`, `--supersedes`, `--consequences`; verify a test parses stdout JSON with the handler's fields, and a re-record of a decided decision reports `alreadyDecided` + +## 2. Messages and guidance + +- [x] 2.1 Replace both `openlore decisions --record` hints with `openlore decisions record`; verify a source test asserts no `decisions --record` string remains under `src/cli` +- [x] 2.2 When `record_decision` is not wired, make the generated decisions section name `openlore decisions record` (keep the enabling-preset hint); verify `guidance-preset-coherence.test.ts` passes with the updated assertions + +## 3. Docs and checks + +- [x] 3.1 Document the command in `docs/cli-reference.md` and add a `CHANGELOG.md` Unreleased entry; verify the doc-claims guard passes +- [x] 3.2 Run typecheck, lint, and the decisions, guidance, and doc-claims tests; verify all pass and `openspec validate add-decisions-record-cli --strict` passes diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index a09ce460..c491a525 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -2491,6 +2491,63 @@ log. - **WHEN** both logs are produced - **THEN** the two logs are byte-identical and the finding carries a logical location only +### Requirement: DecisionsCanBeRecordedFromTheCli + +`openlore decisions record` SHALL record a draft architectural decision for the repository in the +current directory, with the same behavior as the `record_decision` MCP tool: the same decision id, +the same scope inference, the same anchors, the same "already decided" verdict for a decision that +consolidation has decided, and the same background consolidation. The command SHALL accept +`--title` and `--rationale` (both required), and `--consequences`, `--files` (comma-separated +paths), `--supersedes`, `--scope` (`local`, `component`, `cross-domain`, or `system`), +`--constraints-file` (a JSON constraint block), and `--json`. The command SHALL work whatever MCP +preset is wired. When the input is not valid or the handler returns an error, the command SHALL +write no draft and SHALL exit with a non-zero code. + +Every CLI message that tells the user how to record a decision SHALL name +`openlore decisions record`. When `record_decision` is not part of the wired preset, the generated +agent guidance SHALL name `openlore decisions record` as the way to record a decision. + +#### Scenario: Record a draft with default settings + +- **GIVEN** a repository wired with the default MCP preset +- **WHEN** the user runs `openlore decisions record --title "Use UUIDs" --rationale "Collision-free ids"` +- **THEN** a draft decision is stored with that title and rationale +- **AND** the output names the draft id and the command that reads its verdict + +#### Scenario: JSON output matches the MCP result + +- **WHEN** the user runs the command with `--json` +- **THEN** stdout is one JSON object with the same fields the `record_decision` tool returns + +#### Scenario: Missing rationale + +- **WHEN** the user runs the command without `--rationale` +- **THEN** no draft is stored +- **AND** the command exits with a non-zero code and names the missing option + +#### Scenario: Invalid scope or constraint file + +- **WHEN** the user passes `--scope wide`, or a `--constraints-file` that is not valid JSON +- **THEN** no draft is stored and the command exits with a non-zero code + +#### Scenario: Re-recording a decided decision + +- **GIVEN** a decision that consolidation has already promoted, merged, or rejected +- **WHEN** the user records the same decision again +- **THEN** the output reports that verdict and no new draft is created + +#### Scenario: Gate message names the command + +- **WHEN** the decisions gate blocks a commit for an undocumented change +- **THEN** its message names `openlore decisions record` +- **AND** no CLI message names `openlore decisions --record` + +#### Scenario: Guidance without the MCP tool + +- **GIVEN** a repository whose wired preset does not include `record_decision` +- **WHEN** agent guidance is generated +- **THEN** the decisions section names `openlore decisions record` + ## Technical Notes - **Dependencies**: ora, logger, ProgressIndicator, showNextSteps, @inquirer/prompts diff --git a/src/cli/commands/decisions-record.test.ts b/src/cli/commands/decisions-record.test.ts new file mode 100644 index 00000000..554a03dd --- /dev/null +++ b/src/cli/commands/decisions-record.test.ts @@ -0,0 +1,173 @@ +/** + * CLI tests for `openlore decisions record` (change: add-decisions-record-cli): + * the command records the same draft as the record_decision MCP tool, rejects + * bad input without writing, reports an already-decided verdict, and every CLI + * hint names the real command. + */ + +import { vi } from 'vitest'; + +// Background consolidation spawns a child; a mock child must emit 'spawn' or +// the handler waits (same double as mcp-handlers/decisions.test.ts). +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(() => { + const listeners: Record void>> = {}; + const child = { + unref: vi.fn(), + on(event: string, cb: (...a: unknown[]) => void) { + (listeners[event] ??= []).push(cb); + return child; + }, + }; + queueMicrotask(() => (listeners['spawn'] ?? []).forEach((cb) => cb())); + return child; + }), + }; +}); + +vi.mock('../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn(), section: vi.fn(), discovery: vi.fn(), analysis: vi.fn(), blank: vi.fn() }, +})); + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { Command, Option } from 'commander'; +import { decisionsCommand } from './decisions.js'; +import { loadDecisionStore, saveDecisionStore } from '../../core/decisions/store.js'; +import { logger } from '../../utils/logger.js'; + +// Commander keeps option values between parseAsync() calls on one instance. +function resetCommanderState(root: Command): void { + for (const c of [root, ...root.commands]) { + for (const o of (c as unknown as { options: Option[] }).options) { + c.setOptionValue(o.attributeName(), o.defaultValue); + } + } +} + +describe('openlore decisions record', () => { + let dir: string; + let stdout: string[]; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'openlore-decisions-record-')); + vi.spyOn(process, 'cwd').mockReturnValue(dir); + stdout = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout.push(String(chunk)); + return true; + }); + resetCommanderState(decisionsCommand); + vi.mocked(logger.success).mockClear(); + vi.mocked(logger.error).mockClear(); + vi.mocked(logger.warning).mockClear(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = undefined; + await rm(dir, { recursive: true, force: true }); + }); + + const run = (...args: string[]) => decisionsCommand.parseAsync(['node', 'decisions', 'record', ...args]); + const jsonOut = () => JSON.parse(stdout.join('')) as Record; + + it('records a draft and names its id and the verdict command', async () => { + await run('--title', 'Use UUIDs for decision IDs', '--rationale', 'Collision-free ids across sessions'); + + expect(vi.mocked(logger.error).mock.calls, 'record logged an error').toEqual([]); + const store = await loadDecisionStore(dir); + expect(store.decisions).toHaveLength(1); + expect(store.decisions[0]).toMatchObject({ + status: 'draft', + title: 'Use UUIDs for decision IDs', + rationale: 'Collision-free ids across sessions', + }); + expect(process.exitCode).toBeUndefined(); + // validateDirectory also logs success; pick the record message. + const message = vi.mocked(logger.success).mock.calls.map(([text]) => String(text)).find((text) => text.startsWith('Draft decision recorded')) ?? ''; + expect(message).toContain(`openlore decisions status ${store.decisions[0].id}`); + }); + + it('prints the handler result as JSON and forwards the optional fields', async () => { + await run( + '--title', 'Cache specs in memory', '--rationale', 'Avoid rereading spec files', + '--consequences', 'Stale until restart', '--files', 'src/a.ts, src/b.ts', + '--supersedes', 'a1b2c3d4', '--scope', 'component', '--json', + ); + + const result = jsonOut(); + expect(result.error, 'record returned an error').toBeUndefined(); + expect(result).toMatchObject({ status: 'draft', disposition: 'pending', reason: 'awaiting-consolidation' }); + expect(result.readVerdictWith).toBe(`openlore decisions status ${String(result.id)}`); + const [decision] = (await loadDecisionStore(dir)).decisions; + expect(decision).toMatchObject({ + consequences: 'Stale until restart', + affectedFiles: ['src/a.ts', 'src/b.ts'], + supersedes: 'a1b2c3d4', + scope: 'component', + }); + }); + + it('stores nothing and exits non-zero without --rationale', async () => { + await run('--title', 'Use UUIDs'); + expect(process.exitCode).toBe(1); + expect(String(vi.mocked(logger.error).mock.calls[0]?.[0])).toContain('--rationale'); + expect((await loadDecisionStore(dir)).decisions).toHaveLength(0); + }); + + it('stores nothing and exits non-zero for an unknown scope', async () => { + await run('--title', 'Use UUIDs', '--rationale', 'Why', '--scope', 'wide', '--json'); + expect(process.exitCode).toBe(1); + expect(String(jsonOut().error)).toContain('--scope'); + expect((await loadDecisionStore(dir)).decisions).toHaveLength(0); + }); + + it('stores nothing and exits non-zero for a constraints file that is not JSON', async () => { + const file = join(dir, 'constraints.json'); + await writeFile(file, '{ not json'); + await run('--title', 'Use UUIDs', '--rationale', 'Why', '--constraints-file', file, '--json'); + expect(process.exitCode).toBe(1); + expect(String(jsonOut().error)).toContain('--constraints-file'); + expect((await loadDecisionStore(dir)).decisions).toHaveLength(0); + }); + + it('reports the verdict for a decision that was already decided, without a new draft', async () => { + await run('--title', 'Use UUIDs', '--rationale', 'Collision-free ids'); + const store = await loadDecisionStore(dir); + await saveDecisionStore(dir, { + ...store, + // Consolidation rejected it with a stated reason (the handler test's seeding). + decisions: store.decisions.map((d) => ({ + ...d, + status: 'rejected' as const, + disposition: 'rejected' as const, + dispositionReason: 'not-in-consolidated-set' as const, + })), + }); + + stdout = []; + resetCommanderState(decisionsCommand); + await run('--title', 'Use UUIDs', '--rationale', 'Collision-free ids', '--json'); + + expect(jsonOut()).toMatchObject({ alreadyDecided: true, status: 'rejected' }); + expect((await loadDecisionStore(dir)).decisions).toHaveLength(1); + }); + + it('never points users at the non-existent `decisions --record` option', async () => { + const root = new URL('..', import.meta.url); + const offenders: string[] = []; + for (const entry of await readdir(root, { recursive: true })) { + const path = String(entry); + if (!path.endsWith('.ts') || path.endsWith('.test.ts')) continue; + const source = await readFile(new URL(path, root), 'utf8'); + if (source.includes('decisions --record')) offenders.push(path); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/src/cli/commands/decisions.ts b/src/cli/commands/decisions.ts index a39fcc4d..694b1745 100644 --- a/src/cli/commands/decisions.ts +++ b/src/cli/commands/decisions.ts @@ -59,7 +59,8 @@ import { CONSOLIDATION_GRACE_PERIOD_MS, GATE_REASONS, } from '../../constants.js'; -import type { DecisionStore, PendingDecision } from '../../types/index.js'; +import type { DecisionConstraintBlock, DecisionScope, DecisionStore, PendingDecision } from '../../types/index.js'; +import { handleRecordDecision } from '../../core/services/mcp-handlers/decisions.js'; import { runTuiApproval } from '../tui-approval.js'; import { emit } from '../../core/services/telemetry.js'; import { resolveOpenspecDir } from '../../utils/openspec-dir.js'; @@ -895,7 +896,7 @@ the gate auto-accepts verified decisions, syncs them to specs marked "Auto-accep console.log(` git restore ${f}`); } console.log('\nOr to document why this approach was rejected:'); - console.log(' openlore decisions --record'); + console.log(' openlore decisions record --title "" --rationale ""'); console.log(' (then re-run --consolidate before committing)'); } return; @@ -1204,7 +1205,7 @@ the gate auto-accepts verified decisions, syncs them to specs marked "Auto-accep console.log('Sync all approved: openlore decisions --sync'); if (options.gate && missing.length > 0) { - logger.warning(`\nCommit gated — ${missing.length} undocumented change(s) require a decision. Record with: openlore decisions --record or record_decision MCP tool.`); + logger.warning(`\nCommit gated — ${missing.length} undocumented change(s) require a decision. Record with: openlore decisions record --title "" --rationale "" (or the record_decision MCP tool).`); process.exitCode = 1; } else if (options.gate && unresolvedPhantom.length > 0) { logger.warning(`\nCommit gated — ${unresolvedPhantom.length} phantom classification(s) changed concurrently and remain unresolved.`); @@ -1651,6 +1652,90 @@ decisionsCommand } }); +const DECISION_SCOPES: readonly DecisionScope[] = ['local', 'component', 'cross-domain', 'system']; + +// The CLI twin of the record_decision MCP tool, so any wired preset can record a +// decision (change: add-decisions-record-cli). It calls the same handler, so ids, +// scope inference, anchors, the already-decided verdict, and background +// consolidation stay identical across both entry points. +decisionsCommand + .command('record') + .description('Record a draft architectural decision (same as the record_decision MCP tool; works with any wired preset)') + .option('--title ', 'REQUIRED. Short imperative statement, e.g. "Use UUIDs for decision IDs"') + .option('--rationale ', 'REQUIRED. Why this decision was made') + .option('--consequences ', 'What changes as a result') + .option('--files ', 'Comma-separated source files most relevant to this decision') + .option('--supersedes ', 'ID of a prior decision this one replaces') + .option('--scope ', `Decision scope: ${DECISION_SCOPES.join(' | ')} (default: inferred)`) + .option('--constraints-file ', 'JSON file holding a versioned decision constraint block') + .option('--json', 'Output as JSON', false) + .action(async (opts: { + title?: string; + rationale?: string; + consequences?: string; + files?: string; + supersedes?: string; + scope?: string; + constraintsFile?: string; + json: boolean; + }, cmd: Command) => { + const parentOpts = (cmd.parent?.opts() ?? {}) as { json?: boolean }; + const json = Boolean(opts.json || parentOpts.json); + const restoreStdout = json ? redirectConsoleToStderr() : null; + const fail = (message: string): void => { + if (json) process.stdout.write(JSON.stringify({ error: message }, null, 2) + '\n'); + else logger.error(`decisions record failed: ${safe(message)}`); + process.exitCode = 1; + }; + try { + const title = opts.title?.trim(); + const rationale = opts.rationale?.trim(); + if (!title) return fail('--title is required and must not be empty.'); + if (!rationale) return fail('--rationale is required and must not be empty.'); + if (opts.scope !== undefined && !DECISION_SCOPES.includes(opts.scope as DecisionScope)) { + return fail(`--scope must be one of: ${DECISION_SCOPES.join(', ')}.`); + } + let constraints: DecisionConstraintBlock | undefined; + if (opts.constraintsFile) { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(opts.constraintsFile, 'utf-8')); + } catch (err) { + return fail(`--constraints-file could not be read as JSON: ${(err as Error).message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return fail('--constraints-file must contain a JSON object.'); + } + constraints = parsed as DecisionConstraintBlock; + } + const files = (opts.files ?? '').split(',').map((file) => file.trim()).filter(Boolean); + + const result = await handleRecordDecision( + process.cwd(), + title, + rationale, + opts.consequences, + files.length > 0 ? files : undefined, + opts.supersedes, + opts.scope as DecisionScope | undefined, + constraints, + ) as Record; + if (typeof result.error === 'string') return fail(result.error); + + if (json) { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + return; + } + if (result.alreadyDecided) logger.warning(safe(String(result.message))); + else logger.success(safe(String(result.message))); + console.log(` id: ${safe(String(result.id))}`); + } catch (err) { + fail((err as Error).message); + } finally { + restoreStdout?.(); + } + }); + /** Parse a `--promote/--reject` id list ("all" or comma-separated 8-char ids) against the queue. */ function resolveReviewIds(raw: string, queue: PendingDecision[]): string[] { if (raw.trim().toLowerCase() === 'all') return queue.map((d) => d.id); diff --git a/src/core/analyzer/ai-config-generator.ts b/src/core/analyzer/ai-config-generator.ts index 8e3f2ea3..9248c529 100644 --- a/src/core/analyzer/ai-config-generator.ts +++ b/src/core/analyzer/ai-config-generator.ts @@ -190,14 +190,19 @@ function buildMcpSection( if (has('record_decision')) { lines.push(DECISIONS_BODY); } else { - // Honest form: state the prerequisite instead of prescribing a tool the - // agent cannot call. This is the exact failure this change exists to fix. + // Honest form: never prescribe a tool the agent cannot call. The CLI records + // the same draft on any preset (change: add-decisions-record-cli). lines.push( - `\`record_decision\` is **not** part of the wired \`${preset}\` surface, so the decision-recording workflow is unavailable in this repository.`, + `\`record_decision\` is **not** part of the wired \`${preset}\` surface, so do not plan around calling it.`, '', - `To enable it: \`${enablingCommand}\` — then this section regenerates with the full workflow.`, + 'When making a significant design choice, record it with the CLI **before** writing the code:', '', - 'Until then, do not plan around `record_decision`: it will not be callable.', + '```bash', + 'openlore decisions record --title "Use JWTs for stateless auth" --rationale "Avoids session store in infra" \\', + ' --consequences "Tokens can\'t be revoked early" --files src/auth/middleware.ts', + '```', + '', + `To expose the MCP tool as well: \`${enablingCommand}\` — then this section regenerates with the MCP workflow.`, ); } diff --git a/src/core/analyzer/guidance-preset-coherence.test.ts b/src/core/analyzer/guidance-preset-coherence.test.ts index 3c0b4c58..3ae8f713 100644 --- a/src/core/analyzer/guidance-preset-coherence.test.ts +++ b/src/core/analyzer/guidance-preset-coherence.test.ts @@ -112,6 +112,8 @@ describe('generated guidance ↔ wired preset coherence', () => { expect(guidance).toContain('`record_decision` is **not** part of the wired'); expect(guidance).toContain('openlore install --preset'); expect(guidance).not.toContain('record_decision({'); // no callable example + // The CLI records the same draft on any preset (change: add-decisions-record-cli). + expect(guidance).toContain('openlore decisions record --title'); }); it('never advises a preset that would drop tools already wired', async () => { From d4895e022e0330a89006add73cdabc178fb01ee1 Mon Sep 17 00:00:00 2001 From: Laurent FRANCOISE Date: Tue, 15 Sep 2026 23:01:03 +0200 Subject: [PATCH 3/3] docs: changelog and CLI reference for Pi lean surface and decisions record Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 ++++++++++++++ docs/cli-reference.md | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6c7f89..f74a2dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to OpenLore are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- **Pi starts with the same lean tool surface as Claude Code.** A Pi session now activates only + the `substrate` tools (the default `openlore install` wires for MCP hosts), plus + `openlore_configure` and the new `openlore_activate_tools`. The activator turns on the `specs`, + `memory`, `review`, `quality`, or `inspect` group, by group name or by any tool name in it. Tool + snippets are one line instead of a repeated description. Set `"pi": { "toolSurface": "all" }` in + `.openlore/config.json` to keep every tool active (#505). +- **Record decisions from the CLI.** `openlore decisions record --title … --rationale …` records the + same draft as the `record_decision` MCP tool, so a repository on the default preset can pass the + decisions gate. The gate and rejection hints no longer name the non-existent + `openlore decisions --record`, and generated agent guidance names the CLI command when the MCP + tool is not wired. + ## [3.2.0] - 2026-09-13 **The release where OpenLore checks the locks before reading the map.** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5571b7ec..22b21d37 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -358,6 +358,12 @@ openlore decisions [options] --json # Machine-readable output --uninstall-hook # Remove decisions pre-commit hook (install via: openlore setup --tools claude) +# Record a draft decision — the same draft the record_decision MCP tool records, on any wired preset +openlore decisions record --title --rationale + [--consequences ] [--files ] [--supersedes ] + [--scope local|component|cross-domain|system] [--constraints-file ] [--json] + # Prints the draft id and `openlore decisions status ` for its verdict + # Decision autopilot (opt-in: { "governance": { "autopilot": true } } in .openlore/config.json): # the gate auto-accepts verified decisions (distinct `auto-approved` status), syncs them to # specs with an "Auto-accepted (unreviewed)" marker, and never blocks a commit. Every status @@ -744,6 +750,10 @@ field in openlore's `package.json`) and `openlore setup --tools pi --global` serve daemon, injecting structural context and exposing Pi's curated tool surface. Pi starts a full-preset backing daemon and curates the model-visible tools itself; if an existing narrow daemon owns the repository, stop it before starting Pi. +A session starts with the `substrate` tools (the MCP default), `openlore_configure`, +and `openlore_activate_tools`, which turns on the `specs`, `memory`, `review`, +`quality`, or `inspect` group. `"pi": { "toolSurface": "all" }` in +`.openlore/config.json` keeps every tool active. Its `openlore_prepare_spec_generation` and `openlore_prepare_spec_repair` tools compose the same deterministic daemon evidence used by MCP hosts; Pi writes or reconciles the specification itself and OpenLore makes no internal LLM call.