Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .pi/extensions/sctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function (pi) {
const mutatingTools = new Set(["edit", "write"]);

pi.on("tool_call", async (event, ctx) => {
const text = callSctx("tool_call", event, ctx.cwd);
const text = callSctx("tool_call", event, ctx.cwd, pi);
if (!text) return;

if (mutatingTools.has(event.toolName)) {
Expand All @@ -24,7 +24,7 @@ export default function (pi) {
pi.on("tool_result", async (event, ctx) => {
const before = pending.get(event.toolCallId);
pending.delete(event.toolCallId);
const after = callSctx("tool_result", event, ctx.cwd);
const after = callSctx("tool_result", event, ctx.cwd, pi);
const parts = [before, after].filter(Boolean);
if (parts.length > 0) {
return {
Expand All @@ -34,14 +34,27 @@ export default function (pi) {
});
}

function callSctx(event, toolEvent, cwd) {
// Detect planning mode by checking if mutating tools are absent from the
// active tool set. When pi's plan-mode extension is active it restricts
// available tools to read-only ones. This is a heuristic — it depends on
// the plan-mode extension being installed and using getActiveTools().
function isPlanningMode(pi): boolean {
if (typeof pi.getActiveTools !== "function") return false;
const active = pi.getActiveTools();
if (!active || active.length === 0) return false;
return !active.some((t) => t === "edit" || t === "write");
}

function callSctx(event, toolEvent, cwd, pi) {
try {
const includeDecisions = isPlanningMode(pi);
const payload = JSON.stringify({
source: "pi",
event: event,
tool_name: toolEvent.toolName,
input: toolEvent.input,
cwd: cwd,
include_decisions: includeDecisions,
});
const result = execSync("sctx hook", {
input: payload,
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Or let sctx configure it automatically:
sctx claude enable
```

`sctx hook` reads the hook JSON from stdin, figures out the file path and action from the tool call, resolves matching context entries, and returns them as `additionalContext` in Claude Code's expected format. Decisions are also included when Claude Code's `permission_mode` is `"plan"`, surfacing architectural decisions during planning before the agent writes code. Outside plan mode, decisions are excluded to keep token costs low. If nothing matches, it exits silently.
`sctx hook` reads the hook JSON from stdin, figures out the file path and action from the tool call, resolves matching context entries, and returns them as `additionalContext` in the agent's expected format. For Claude Code, decisions are included when `permission_mode` is `"plan"`, surfacing architectural decisions during planning before the agent writes code; outside plan mode, decisions are excluded to keep token costs low. For pi, decisions are always included alongside context. If nothing matches, it exits silently.

The Write tool gets special handling: `sctx` checks whether the file exists on disk to distinguish `create` from `edit`.

Expand Down Expand Up @@ -181,7 +181,7 @@ Response without New Zealand reference.

## CLI commands

**sctx hook** - Reads agent hook input from stdin, returns matching context entries (and decisions during plan mode). This is the main integration point.
**sctx hook** - Reads agent hook input from stdin, returns matching context entries and decisions. This is the main integration point.

**sctx context \<path\>** - Query context entries for a file or directory. Supports `--on <action>`, `--when <timing>`, `--json`, and `--all` to dump every entry from every AGENTS.yaml.

Expand Down Expand Up @@ -222,7 +222,7 @@ Install the sctx extension into your project's `.pi/extensions/` directory:
sctx pi enable
```

This creates a thin TypeScript extension that hooks into pi's `tool_call` and `tool_result` events. When pi reads, writes, or edits a file, the extension forwards the event to `sctx hook` and injects any matching context into the tool result.
This creates a thin TypeScript extension that hooks into pi's `tool_call` and `tool_result` events. When pi reads, writes, or edits a file, the extension forwards the event to `sctx hook` and injects any matching context and decisions into the tool result.

To remove:

Expand Down
10 changes: 10 additions & 0 deletions internal/adapter/AGENTS.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ decisions:
revisit_when: "A third adapter is introduced"
date: 2026-03-14

- decision: "Auto-detect planning mode in pi extension via getActiveTools() with manual override (#125)"
rationale: "Pi has no built-in planning mode — it is an optional extension. The best proxy is checking pi.getActiveTools() for the absence of mutating tools (edit, write). The extension auto-sets include_decisions: true when in planning mode so decisions surface without manual opt-in. The Go-side include_decisions field remains the contract, meaning the extension can be swapped or overridden without Go changes. This is a heuristic dependent on the plan-mode extension being installed."
alternatives:
- option: "Manual opt-in only"
reason_rejected: "Requires users to configure include_decisions themselves; decisions would never surface automatically during planning"
- option: "Automatic detection only, no manual override"
reason_rejected: "No escape hatch if the heuristic misfires or users want decisions outside planning mode"
revisit_when: "Pi adds native planning-mode events to its event bus"
date: 2026-04-10

- decision: "Decline speculative bashReadPath hardening for redirects, heredocs, and subshells (#81)"
rationale: "bashReadPath already has 95.2% line coverage with 14 test cases. The proposed edge cases (heredocs, redirects, subshells) are hypothetical — no user has reported hitting them. Maintainer prefers a reactive approach: fix issues as they arise in practice rather than preemptively handling unlikely inputs."
revisit_when: "A user reports bashReadPath returning incorrect paths for real-world commands"
Expand Down
26 changes: 19 additions & 7 deletions internal/adapter/pi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import (

// PiHookInput represents the JSON that the pi extension sends via stdin to sctx hook.
type PiHookInput struct {
Source string `json:"source"`
Event string `json:"event"`
ToolName string `json:"tool_name"`
Input json.RawMessage `json:"input"`
CWD string `json:"cwd"`
Source string `json:"source"`
Event string `json:"event"`
ToolName string `json:"tool_name"`
Input json.RawMessage `json:"input"`
CWD string `json:"cwd"`
IncludeDecisions bool `json:"include_decisions,omitempty"`
}

// piToolInput extracts the path from pi tool input shapes.
Expand Down Expand Up @@ -100,12 +101,23 @@ func HandlePiHook(input []byte, out, errOut io.Writer) error {
_, _ = fmt.Fprintln(errOut, w) // best-effort; write failures non-fatal
}

if len(result.ContextEntries) == 0 {
hasContext := len(result.ContextEntries) > 0
hasDecisions := hookInput.IncludeDecisions && len(result.DecisionEntries) > 0

if !hasContext && !hasDecisions {
return nil
}

var additionalContext string
if hasContext {
additionalContext = formatContext(result.ContextEntries)
}
if hasDecisions {
additionalContext += formatDecisions(result.DecisionEntries)
}

output := PiHookOutput{
AdditionalContext: formatContext(result.ContextEntries),
AdditionalContext: additionalContext,
}

return json.NewEncoder(out).Encode(output)
Expand Down
112 changes: 112 additions & 0 deletions internal/adapter/pi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,118 @@ func TestHandlePiHook_MalformedInput(t *testing.T) {
}
}

// runPiDecisionHook sets up a test directory, creates a target file, and runs the pi hook.
func runPiDecisionHook(t *testing.T, yaml, tool string, includeDecisions bool) *PiHookOutput {
t.Helper()

tmpDir := setupPiTestDir(t, yaml)
target := filepath.Join(tmpDir, "file.go")

if err := os.WriteFile(target, []byte("package main"), 0o600); err != nil {
t.Fatal(err)
}

_, out := runPiHook(t, PiHookInput{
Source: "pi",
Event: "tool_call",
ToolName: tool,
Input: json.RawMessage(`{"path":"` + target + `"}`),
CWD: tmpDir,
IncludeDecisions: includeDecisions,
})

return out
}

func TestHandlePiHook_Decisions(t *testing.T) {
agentsDecisionsOnly := `
decisions:
- decision: "REST over GraphQL"
rationale: "Simpler client integration"
`
agentsWithBoth := `
context:
- content: "Edit guidance"
on: edit
when: before
decisions:
- decision: "Use Postgres over MySQL"
rationale: "Better JSON support"
alternatives:
- option: "MySQL"
reason_rejected: "Weaker JSON querying"
revisit_when: "MySQL adds comparable JSON support"
`
agentsContextOnly := `
context:
- content: "Style guide"
on: edit
when: before
`

t.Run("decisions included when opted in", func(t *testing.T) {
out := runPiDecisionHook(t, agentsDecisionsOnly, "read", true)

if out == nil {
t.Fatal("expected output for decisions-only AGENTS.yaml")
}
if !strings.Contains(out.AdditionalContext, "## Architectural Decisions") {
t.Errorf("expected decisions section, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "REST over GraphQL") {
t.Errorf("expected decision text, got: %s", out.AdditionalContext)
}
})

t.Run("decisions excluded by default", func(t *testing.T) {
out := runPiDecisionHook(t, agentsDecisionsOnly, "read", false)

if out != nil {
t.Errorf("expected no output when include_decisions is false, got: %s", out.AdditionalContext)
}
})

t.Run("both context and decisions with full detail", func(t *testing.T) {
out := runPiDecisionHook(t, agentsWithBoth, "edit", true)

if out == nil {
t.Fatal("expected output for both context and decisions")
}
if !strings.Contains(out.AdditionalContext, "## Structured Context") {
t.Errorf("expected context section, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "## Architectural Decisions") {
t.Errorf("expected decisions section, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "Use Postgres over MySQL") {
t.Errorf("expected decision text, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "Better JSON support") {
t.Errorf("expected rationale, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "Considered MySQL, rejected: Weaker JSON querying") {
t.Errorf("expected alternatives, got: %s", out.AdditionalContext)
}
if !strings.Contains(out.AdditionalContext, "Revisit when: MySQL adds comparable JSON support") {
t.Errorf("expected revisit_when, got: %s", out.AdditionalContext)
}
})

t.Run("context only no decisions section", func(t *testing.T) {
out := runPiDecisionHook(t, agentsContextOnly, "edit", true)

if out == nil {
t.Fatal("expected output for context-only")
}
if !strings.Contains(out.AdditionalContext, "Style guide") {
t.Errorf("expected context, got: %s", out.AdditionalContext)
}
if strings.Contains(out.AdditionalContext, "Architectural Decisions") {
t.Errorf("did not expect decisions section, got: %s", out.AdditionalContext)
}
})
}

func TestIsPiHook(t *testing.T) {
tests := []struct {
name string
Expand Down