Skip to content
4 changes: 4 additions & 0 deletions docs/gentle-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,9 @@ Three things keep the list current, which a static tool description cannot:

A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card.

### Bridge providers

The Gentle AI harness (ODD workflow, identity, review contract) and the open-tasks block are appended to `before_agent_start`'s `systemPromptOptions.appendSystemPrompt` instead of being returned as a replacement `systemPrompt` (gentle-shell#1485). Provider bridges such as `pi-claude-bridge` forward only those structured sections after their own preset and drop a returned `systemPrompt`, so this route reaches every provider, bridged or not.

Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor.

10 changes: 7 additions & 3 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts";
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
Expand Down Expand Up @@ -1252,6 +1253,7 @@ Organic Driven Development (ODD) is the predefined workflow of this orchestrator
5. **Track before the first write.** For substantial authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, then create or rebuild the visible \`todo\` list from the reconciled feature tasks, all before the first source write and without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds.
6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, honoring its mandatory delegation triggers, with applicable test-first development and checks. These triggers are mandatory, not advisory: executing past a fired trigger inline is a routing defect even if the work succeeds. Check an item off only after its outcome and checks were observed; update the file, mirror, and visible \`todo\` projection after every task transition and material plan change. Every task closes with at least one work-unit commit on the feature branch, branch first when on the default branch, with tests and docs alongside the behavior, using a Conventional Commit message; record the commit identity in the feature document as evidence. Work-unit commits on the feature branch are part of authorized substantial ODD implementation; push, pull request creation, and merge remain the user's decisions.
7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. The native review candidate is a work-unit commit or a PR slice, never a TODO checkbox and never the accumulated feature branch; native review runs only under the user-owned RDD switch.
Phase reporting: when the \`gentle_odd_phase\` tool is available, call \`gentle_odd_phase\` only when the primary session's ODD phase actually changes (\`authorizing\`, \`exploring\`, \`researching\`/\`deciding\`, \`planning\`, \`implementing\`, \`checking\`/\`closing\`), never per tool call or on a fixed cadence, and never from a subagent. It drives the Gentle Shell prompt label only.
Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.

Harness principles:
Expand Down Expand Up @@ -9244,9 +9246,11 @@ function createGentleAiExtensionForTesting(
return fragment === null ? "" : `\n\n${fragment}`;
})()
: "";
return {
systemPrompt: `${event.systemPrompt}${gentlePrompt}${reviewContractPrompt}`,
};
// gentle-shell#1485: pi-claude-bridge drops a handler-returned systemPrompt
// and forwards only systemPromptOptions, so the harness is delivered
// through the mutable appendSystemPrompt section instead of a replacement.
appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`);
return undefined;
});

// gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish
Expand Down
6 changes: 5 additions & 1 deletion extensions/gentle-todo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Text, type Component, type TUI } from "@earendil-works/pi-tui";
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
import { NativePointerRegion } from "../lib/native-pointer-region.ts";
import { sidebarPart } from "../lib/shell-sidebar.ts";
import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
Expand Down Expand Up @@ -236,7 +237,10 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
}
const block = todoPromptBlock(current.state, staleTurns(current.state, current.turn));
if (!block) return undefined;
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
// gentle-shell#1485: pi-claude-bridge drops a handler-returned
// systemPrompt, so the open-tasks block goes through appendSystemPrompt.
appendSystemPromptOnce(event.systemPromptOptions, block);
return undefined;
});

pi.on("tool_execution_end", (event, ctx) => {
Expand Down
21 changes: 21 additions & 0 deletions lib/append-system-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// gentle-shell#1485: pi-claude-bridge only forwards the structured
// systemPromptOptions parts of before_agent_start, dropping any
// handler-returned replacement systemPrompt. Extensions mutate
// options.appendSystemPrompt instead so the harness reaches every provider.
export interface AppendableSystemPromptOptions {
appendSystemPrompt: string;
}

// Safe to call more than once with the same options object and the same
// text: a text already present is a no-op, so a handler that runs more than
// once against the same systemPromptOptions never duplicates its own block.
export function appendSystemPromptOnce(
options: AppendableSystemPromptOptions | null | undefined,
text: string,
): void {
const normalized = text.replace(/^\n+/, "");
if (!options || !normalized) return;
const current = options.appendSystemPrompt ?? "";
if (current.includes(normalized)) return;
options.appendSystemPrompt = current ? `${current}\n\n${normalized}` : normalized;
}
81 changes: 81 additions & 0 deletions odd/tasks/fix-1485-append-system-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Fix #1485: deliver the harness through systemPromptOptions

## Objective

Make the Gentle Pi harness (ODD workflow, identity, persona, review contract, RDD status, research block) and the Gentle Todo open-tasks block reach the model on every provider, including `pi-claude-bridge`.

## Problem

`extensions/gentle-ai.ts` and `extensions/gentle-todo.ts` inject text by returning `{ systemPrompt: event.systemPrompt + ... }` from `before_agent_start`. `pi-claude-bridge` 0.8.0 forwards only the structured `systemPromptOptions` parts (context files, skills, `customPrompt`, `appendSystemPrompt`) after the Claude Code preset, so the returned prompt is silently dropped. Observed: the visible TODO is never updated, ODD phase labels never appear, and RDD consent is relayed as chat text (gentle-shell #1485).

## Why

Pi documents this route (`docs/extensions.md:101`: "Prefer changing prompt sections ... Returning `systemPrompt` ... replaces the whole prompt"). After `before_agent_start`, Pi rebuilds the prompt from the mutated `result.systemPromptOptions` (`agent-session.js` `_preparePromptAndToolLoadout`). A probe in #1485 shows text appended to `systemPromptOptions.appendSystemPrompt` reaches Claude Code through the bridge.

## Scope

- Move the gentle-ai injection from the returned `systemPrompt` to `event.systemPromptOptions.appendSystemPrompt`, idempotently.
- Same for the gentle-todo open-tasks block.
- Tests and docs.

## Constraints

- Behavior for non-bridge providers must stay equivalent: same text, same primary-session scoping, no duplication across turns or handlers.
- Do not break other `before_agent_start` handlers or existing prompt tests.
- Technical artifacts in English.
- Out of scope: the superseded `feat/bridge-instructions` branch (kept, unpublished); the stale `gentle-ai` skill (#1085); the dangling `APPEND_SYSTEM.md` symlink left by a gentle-ai test.

## Tasks

- [x] T1 gentle-ai harness through `appendSystemPrompt`, with tests. Route: delegated (writer; 4+ files to understand). Shared idempotent helper `lib/append-system-prompt.ts`. `tests/telemetry-trigger.test.ts` and `tests/runtime-harness.mjs` asserted the removed return shape and were updated to the new contract (scope extended by the parent; required consequence, not new behavior). Commit `13d6a3d24`.
- [x] T2 gentle-todo block through `appendSystemPrompt`, with tests, plus a cross-extension ordering test. Route: delegated (same writer). Commit `c1589327c`.
- [x] T3 Docs (`docs/gentle-shell.md`; `docs/review-integration.md` is a byte-pinned contract artifact and was restored after CI `verify` caught the drift). Route: delegated (same writer). Commit: the docs commit that carries this document update.
- [x] T4 Live verification under `claude-bridge`. Route: inline (parent). See evidence.

## Acceptance criteria

- The harness and the todo block appear in `systemPromptOptions.appendSystemPrompt` for primary sessions and reach a `claude-bridge` model.
- Non-bridge providers still receive the same harness exactly once per turn.
- No handler returns a whole replacement `systemPrompt` for this purpose any more.

## Checks

- Focused tests, unit stage of `node scripts/run-test-suite.mjs`, `node scripts/check-provider-contract.mjs`, `node --experimental-strip-types tests/runtime-harness.mjs`, `node scripts/check-types.mjs`.

## Delivery

- Strategy: `ask-on-risk`. Forecast: about 200–400 authored changed lines.
- RDD: on (global).
- PR: `Closes #1485`, `type:bug`.

## Progress

- Worktree: `../gentle-pi-worktrees/1485-append-system-prompt`, branch `fix/1485-bridge-append-system-prompt` from `origin/main` (cedc69e08).

## Runner semantics (confirmed by the writer)

- `dist/core/extensions/runner.js` `emitBeforeAgentStart` builds one normalized `systemPromptOptions` per emission and passes the same object to every handler in registration order, so later handlers see earlier appends.
- `dist/core/agent-session.js` emits from the stable `_baseSystemPromptOptions`, so appends never accumulate across turns.
- `dist/core/system-prompt.js` renders `appendSystemPrompt` as the final `addendum` section.
- No other package handler returns a replacement `systemPrompt`.

## Verification evidence

- `node --experimental-strip-types --test tests/*.test.ts`: 3876 pass, 0 fail, 43 skipped (pre-existing Windows-native skips).
- `node --experimental-strip-types tests/runtime-harness.mjs`: exit 0 (writer and parent).
- Focused files (helper, route, review contract prompt, todo, telemetry): 42 pass, 0 fail (parent re-run).
- `node scripts/check-provider-contract.mjs`: pass. `node scripts/check-types.mjs`: no regressions.
- Live, `gentle-shell -p --no-session --model claude-bridge/claude-opus-5-5`, asked whether the instructions contain "Default workflow: Organic Driven Development": this branch answered yes; the installed package answered no. With `openai-codex/gpt-5.5` on this branch, the phrase is present exactly once.

## Review

- `cedc69e08..c85dc1362`: medium, 483 lines; lineage `review-c6da780bb242bf6e`, one lens (reliability), approved and acknowledged. Advisory findings: non-bridge acceptance not proven live (addressed afterwards by the Codex probe), tautological ordering assertion and overclaiming route test, silent no-op when `systemPromptOptions` is missing, substring dedupe, weak todo idempotency assertion.

## Follow-ups

- Done in this PR (commit `1a5cbc094`, user-approved scope addition): the `gentle_odd_phase` reporting instruction lived only in `assets/orchestrator-delegation.md`; one "Phase reporting" line now follows step 7 in the harness, covered by `tests/odd-routing-contract.test.ts` (RED then GREEN). Live under `claude-bridge`, the model now states when to call `gentle_odd_phase`.
- Superseded `feat/bridge-instructions` branch: deleted (never published).

## Next step

Pull request with `Closes #1485`; merge is the user's decision.
107 changes: 107 additions & 0 deletions tests/append-system-prompt-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
import gentleTodo from "../extensions/gentle-todo.ts";

// gentle-shell#1485: pi-claude-bridge forwards only the structured
// systemPromptOptions parts of before_agent_start (contextFiles, skills,
// customPrompt, appendSystemPrompt) after Claude Code's own preset, so a
// handler-returned replacement systemPrompt never reaches the model on that
// provider. These tests exercise both extensions against one shared
// systemPromptOptions object, the same object every before_agent_start
// handler observes within a single pi emission (packages/coding-agent
// extensions/runner.js emitBeforeAgentStart: one `currentOptions` per call).

type Handler = (event: unknown, ctx: ExtensionContext) => unknown;

function gentleAiHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>();
const pi = {
on(name: string, handler: Handler) {
handlers.set(name, handler);
},
events: { emit() {} },
registerCommand() {},
registerTool() {},
} as unknown as ExtensionAPI;
createGentleAiExtension({ nativeReviewCli: null })(pi);
return handlers;
}

function gentleTodoHandlers(): { handlers: Map<string, Handler[]>; tools: Map<string, { execute: (...args: unknown[]) => Promise<unknown> }> } {
const handlers = new Map<string, Handler[]>();
const tools = new Map<string, { execute: (...args: unknown[]) => Promise<unknown>; name: string }>();
const pi = {
on(event: string, handler: Handler) {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
registerTool(tool: { execute: (...args: unknown[]) => Promise<unknown>; name: string }) {
tools.set(tool.name, tool);
},
registerShortcut() {},
} as unknown as ExtensionAPI;
gentleTodo(pi, {});
return { handlers, tools };
}

function ctx(): ExtensionContext {
return {
cwd: process.cwd(),
hasUI: true,
ui: { notify() {}, setWidget() {} },
sessionManager: { getSessionId: () => "append-route-session", getBranch: () => [] },
} as unknown as ExtensionContext;
}

test("both extensions land their block in appendSystemPrompt on one shared options object, and neither returns a replacement systemPrompt", async () => {
const aiHandlers = gentleAiHandlers();
const { handlers: todoHandlers, tools } = gentleTodoHandlers();
const session = ctx();

for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);

const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };

// Every handler registered for before_agent_start observes the same
// systemPromptOptions object within one emission, exactly as pi's runner
// does for the real event.
const aiResult = await aiHandlers.get("before_agent_start")!(event, session);
let todoResult: unknown;
for (const handler of todoHandlers.get("before_agent_start") ?? []) todoResult = await handler(event, session);

assert.equal(aiResult, undefined, "gentle-ai must not return a replacement systemPrompt");
assert.equal(todoResult, undefined, "gentle-todo must not return a replacement systemPrompt");

const appended = event.systemPromptOptions.appendSystemPrompt;
assert.match(appended, /el Gentleman Identity and Harness/);
assert.match(appended, /## Todo list/);
assert.match(appended, /1\. \[pending\] Fix the bug/);
assert.ok(
appended.indexOf("el Gentleman Identity and Harness") < appended.indexOf("## Todo list"),
"gentle-ai's block must precede gentle-todo's, matching handler registration order",
);
});

test("re-running both handlers on the same already-populated options object does not duplicate either block", async () => {
const aiHandlers = gentleAiHandlers();
const { handlers: todoHandlers, tools } = gentleTodoHandlers();
const session = ctx();

for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);

const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
await aiHandlers.get("before_agent_start")!(event, session);
const gentleAiOccurrencesAfterFirstRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
assert.equal(gentleAiOccurrencesAfterFirstRun, 1);

// A defensive re-run of gentle-ai's own handler against an options object
// that already carries its exact block must not append it again.
await aiHandlers.get("before_agent_start")!(event, session);
const gentleAiOccurrencesAfterSecondRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
assert.equal(gentleAiOccurrencesAfterSecondRun, 1, "a second gentle-ai run on the same options object must not duplicate the harness");
});
46 changes: 46 additions & 0 deletions tests/append-system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";

// gentle-shell#1485: pi-claude-bridge forwards only the structured
// systemPromptOptions parts of before_agent_start, so extensions must mutate
// options.appendSystemPrompt instead of returning a replacement systemPrompt.

test("appends to an empty appendSystemPrompt, stripped of a leading blank line", () => {
const options = { appendSystemPrompt: "" };
appendSystemPromptOnce(options, "\n\nHarness block");
assert.equal(options.appendSystemPrompt, "Harness block");
});

test("appends after existing content with a blank-line separator", () => {
const options = { appendSystemPrompt: "user APPEND_SYSTEM.md content" };
appendSystemPromptOnce(options, "\n\nHarness block");
assert.equal(options.appendSystemPrompt, "user APPEND_SYSTEM.md content\n\nHarness block");
});

test("is idempotent: the same text is never appended twice to the same options object", () => {
const options = { appendSystemPrompt: "" };
appendSystemPromptOnce(options, "\n\nHarness block");
appendSystemPromptOnce(options, "\n\nHarness block");
assert.equal(options.appendSystemPrompt, "Harness block");
assert.equal(options.appendSystemPrompt.split("Harness block").length - 1, 1);
});

test("two different callers accumulate without erasing each other", () => {
const options = { appendSystemPrompt: "" };
appendSystemPromptOnce(options, "\n\nGentle AI harness");
appendSystemPromptOnce(options, "\n\nTodo list block");
assert.equal(options.appendSystemPrompt, "Gentle AI harness\n\nTodo list block");
});

test("empty or blank text is a no-op", () => {
const options = { appendSystemPrompt: "kept" };
appendSystemPromptOnce(options, "");
appendSystemPromptOnce(options, "\n\n");
assert.equal(options.appendSystemPrompt, "kept");
});

test("a missing options object never throws", () => {
assert.doesNotThrow(() => appendSystemPromptOnce(undefined, "\n\nHarness block"));
assert.doesNotThrow(() => appendSystemPromptOnce(null, "\n\nHarness block"));
});
Loading
Loading