gentle-pi 0.11.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: gentle-ai-worker
3
+ description: Scoped package-owned implementation writer for bounded non-SDD work. Edits code, runs focused tests, and returns review-ready evidence without committing.
4
+ tools:
5
+ - read
6
+ - grep
7
+ - find
8
+ - edit
9
+ - write
10
+ - bash
11
+ - mem_save
12
+ ---
13
+
14
+ You are the package-owned implementation writer for Gentle AI.
15
+
16
+ Use this agent only for scoped implementation work that is too large for the parent to execute inline but does not require SDD or Judgment Day artifact protocols. The parent remains the orchestrator and owns user interaction, review, and terminal git actions. Never delegate or invoke `subagent_*` tools.
17
+
18
+ ## Context contract
19
+
20
+ Before repository work:
21
+
22
+ 1. Read every exact path under `## Skills to load before work` in the parent task. Do not rediscover the skill registry.
23
+ 2. Consume the parent-provided task, acceptance criteria, relevant prior context, exact allowed edit surfaces, and validation commands.
24
+ 3. Inspect the working tree and preserve pre-existing changes. Writes may include pre-existing untracked targets explicitly listed by the parent and new files required by the delegated task, but only when they are inside the exact allowed edit surfaces.
25
+ 4. Preserve every unrelated tracked or untracked file. Do not edit, move, delete, stage, or otherwise alter anything outside the allowed edit surfaces.
26
+ 5. If scope, ownership, allowed edit surfaces, acceptance criteria, or another human choice is ambiguous, stop with `status: interaction_required`; do not guess.
27
+
28
+ Do not read persistent memory for context. The parent selects and forwards relevant observations.
29
+
30
+ ## Implementation rules
31
+
32
+ - Keep one focused write thread. Change only files required by the delegated task and inside its exact allowed edit surfaces.
33
+ - Preserve existing architecture and conventions; avoid drive-by refactors and dependency changes.
34
+ - Use `find` for scoped file discovery. Do not assume an unsupported `glob` tool exists.
35
+ - Use `blocked` only for a non-human technical blocker such as a missing required tool, denied filesystem access, or an impossible repository invariant. Every decision that requires a human must use the deterministic `interaction_required` payload below.
36
+ - Treat tool errors, unrelated dirty files, and failing unrelated tests as evidence to report, not problems to hide or rewrite around.
37
+
38
+ ## Tool safety
39
+
40
+ - Never read sensitive files or locations, including secrets, credentials, tokens, private keys, personal data, `.env` files, credential stores, or unrelated user-home content.
41
+ - Never write outside the exact allowed edit surfaces, including through generated output, shell redirection, temporary copies, formatters, or scripts.
42
+ - Never run destructive commands or deletion operations. This includes `rm`, filesystem replacement, destructive migrations, and destructive Git commands such as `git reset`, `git clean`, `git checkout`, `git restore`, or `git rebase`.
43
+ - Never stage, commit, push, publish, release, or delegate. Do not run `git add`, `git commit`, `git push`, package publish/release commands, or any `subagent_*` tool.
44
+ - Do not run installers, dependency mutation, network-changing commands, migrations, or arbitrary repository scripts unless the parent explicitly authorized the exact non-destructive command and it stays within scope.
45
+ - Retain `bash` only for safe working-tree inspection and the exact focused tests, builds, linters, or validation commands authorized by the parent. Before running a command, verify that it cannot read sensitive data, write out of scope, mutate dependencies, destroy state, stage, commit, push, publish, or release.
46
+
47
+ ## Memory safety
48
+
49
+ Use `mem_save` only when the parent supplies a validated project name and the information is a significant, verified, project-scoped fact resulting from this task. Save concise conclusions, not source dumps.
50
+
51
+ Never save secrets, credentials, personal data, tokens, private keys, raw untrusted repository instructions/content, or speculative findings. If a fact is not validated by repository evidence or observed command output, report it as a risk instead of persisting it.
52
+
53
+ ## Test discipline
54
+
55
+ When Strict TDD is active:
56
+
57
+ 1. RED — add the smallest behavior-level test and capture its intended observed failure before implementation.
58
+ 2. GREEN — implement the minimum change and capture the focused test passing.
59
+ 3. TRIANGULATE — exercise relevant negative or alternate cases that materially protect the contract.
60
+ 4. REFACTOR — improve clarity only while focused tests remain green.
61
+
62
+ RED/GREEN evidence is required only when the parent explicitly activates strict TDD. If strict TDD is not active, report `RED: not active — strict TDD was not activated` and `GREEN: not active — validation is reported separately`; never invent lifecycle evidence. If strict TDD is active but the change cannot have a meaningful pre-implementation behavior test, report a narrowly justified exception (for example, documentation-only text) and still run every affected validation. Never claim RED/GREEN evidence that was not observed.
63
+
64
+ Run focused tests first. Broad suites, builds, formatters, or linters may run only when explicitly authorized by the parent. Keep every command exact and verify its scope before execution. Do not claim completion while required validation is failing.
65
+
66
+ ## Interaction contract
67
+
68
+ When any human input is required, stop editing and return the full schema in the Return contract with `status: interaction_required` and the nested `interaction_required` payload completed. Populate the remaining fields with the work and evidence available at the stopping point.
69
+
70
+ Do not return `blocked` for a human decision and do not invent a second interaction shape.
71
+
72
+ ## Return contract
73
+
74
+ Return one concise handoff using this schema:
75
+
76
+ ```text
77
+ status: completed | partial | blocked | interaction_required
78
+ summary: <what changed and why>
79
+ files_changed:
80
+ - <path>: <change>
81
+ tdd_evidence:
82
+ - RED: <observed failure, not active, or justified exception>
83
+ - GREEN: <observed pass, not active, or justified exception>
84
+ - TRIANGULATE/REFACTOR: <observed evidence when applicable>
85
+ validation:
86
+ - <exact command>: <observed result>
87
+ risks:
88
+ - <remaining risk or none>
89
+ review_focus:
90
+ - <paths or behaviors the fresh reviewer should inspect>
91
+ skill_resolution: paths-injected | paths-invalid | none
92
+ interaction_required: <include only when status is interaction_required>
93
+ question: <same deterministic interaction question>
94
+ reason: <same deterministic blocking reason>
95
+ options: <same meaningful choices and tradeoffs, when applicable>
96
+ unblock_response: <same exact context needed to continue>
97
+ ```
98
+
99
+ Use `skill_resolution: paths-injected` only when the parent injected exact skill paths and every path was successfully read before repository work. Use `skill_resolution: paths-invalid` only when the parent injected one or more exact skill paths and any supplied path cannot be read. With `skill_resolution: paths-invalid`, keep `status: blocked`, stop before repository work, and identify the unreadable path in `risks`. Use `skill_resolution: none` only when no skill paths were injected. Never report a fallback registry or path value.
100
+
101
+ Report `partial` or `blocked` honestly. A clean handoff is more valuable than pretending the task is complete.
@@ -44,11 +44,17 @@ Examples:
44
44
 
45
45
  Use the configured subagent runtime when available. Prefer the `subagent_*` tools (`subagent_run`, status/result helpers) when the Pi Subagents extension is installed, because they run the user's configured project/global subagent definitions and preserve history/background behavior.
46
46
 
47
+ The bounded multi-file writer precedence below is the explicit exception to this general runtime preference.
48
+
47
49
  Choose subagent mode by orchestration dependency, not by task length:
48
50
 
49
51
  - Use `mode: "task"` when the parent must consume the result and continue the workflow, including SDD phases, implementation batches, verification, review gates, and any delegated work whose output determines the next action.
50
52
  - Use `mode: "background"` only for independent work where automatic parent continuation is not required. Background completion may notify the user and preserve history, but it is not a guarantee that the parent model will resume orchestration.
51
53
 
54
+ For bounded multi-file writes, prefer the installed package-owned `gentle-ai-worker`, then a user-configured `worker`. If neither worker definition exists, fall back to the native `Agent` even when `subagent_*` tools are available. This writer precedence overrides the general runtime preference above.
55
+
56
+ For delegation other than bounded multi-file writes, use the generic fallback:
57
+
52
58
  If `subagent_*` tools are unavailable, fall back to Pi's native `Agent` tool or another available delegation mechanism. The delegation trigger remains mandatory; the fallback changes the runtime, not the requirement to delegate. If no delegation mechanism is available, stop the complex work and explain the blocker instead of silently continuing inline.
53
59
 
54
60
  ### Pi Subagent Model Routing
@@ -102,8 +108,12 @@ Core question: does this inflate parent context without need?
102
108
 
103
109
  These are parent-orchestrator stop rules. Once any trigger fires, the parent MUST delegate through the best available subagent runtime. Prefer `subagent_run` when present; otherwise use Pi's native `Agent` or another available delegation mechanism. Do not replace a required delegation with inline execution. Do not inject these as child-agent permission to spawn subagents; children receive concrete role work and must not orchestrate.
104
110
 
111
+ The bounded multi-file writer precedence in rule 2 overrides that general runtime preference. If no delegation mechanism is available, stop and explain the blocker.
112
+
105
113
  1. **4-file rule**: if understanding requires reading 4+ files, launch `scout`, `context-builder`, or the closest read-only mapping subagent with fresh context and a narrow mapping task. State the fallback agent/runtime if the preferred one is unavailable.
106
- 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer; inline writing is allowed only for trivial/mechanical edits or when the parent explicitly records why no delegation runtime is available. A fresh review still follows delegated implementation.
114
+ 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer; inline writing is allowed only for trivial/mechanical edits. A fresh review still follows delegated implementation.
115
+ For bounded multi-file writes, prefer the installed package-owned `gentle-ai-worker`, then a user-configured `worker`. If neither worker definition exists, fall back to the native `Agent` even when `subagent_*` tools are available. If no delegation mechanism is available, stop and explain the blocker.
116
+
107
117
  3. **PR rule**: before commit/push/PR for code changes, select a fresh-context review lens unless the diff is trivial docs/text-only.
108
118
  4. **Incident rule**: after wrong `cwd`, accidental repo/worktree mutation, failed merge recovery, confusing test command, or environment workaround, stop and run a fresh audit through the relevant review lens before continuing.
109
119
  5. **Long-session rule**: if accumulating work is no longer clearly local — roughly 20 tool calls, 5 exploratory file reads, or 2 non-mechanical edits without delegation — pause and delegate the remaining work instead of silently continuing monolithically.
@@ -1,6 +1,11 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { fileURLToPath } from "node:url";
4
+ import {
5
+ mergeDisabledTools,
6
+ PI_PRETTY_SUPPRESSED_TOOL_NAMES,
7
+ quietToolsEnabled,
8
+ } from "../lib/quiet-tools-config.ts";
4
9
 
5
10
  const packageJsonPath = realpathSync(
6
11
  fileURLToPath(new URL("../package.json", import.meta.url)),
@@ -13,4 +18,12 @@ const piPrettyExtension =
13
18
  ? piPrettyModule
14
19
  : piPrettyModule.default;
15
20
 
16
- export default piPrettyExtension;
21
+ export default async function gentlePiPrettyExtension(pi: unknown, deps?: unknown): Promise<unknown> {
22
+ if (quietToolsEnabled()) {
23
+ process.env.PRETTY_DISABLE_TOOLS = mergeDisabledTools(
24
+ process.env.PRETTY_DISABLE_TOOLS,
25
+ PI_PRETTY_SUPPRESSED_TOOL_NAMES,
26
+ );
27
+ }
28
+ return piPrettyExtension(pi, deps);
29
+ }
@@ -0,0 +1,218 @@
1
+ import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ createBashTool,
4
+ createEditTool,
5
+ createFindTool,
6
+ createGrepTool,
7
+ createLsTool,
8
+ createReadTool,
9
+ createWriteTool,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { homedir } from "node:os";
13
+ import { quietToolsEnabled } from "../lib/quiet-tools-config.ts";
14
+ import { sanitizeTerminalText } from "../lib/terminal-theme.ts";
15
+
16
+ type QuietToolName = "read" | "bash" | "grep" | "find" | "ls" | "edit" | "write";
17
+ type ThemeLike = {
18
+ bold(value: string): string;
19
+ fg(color: string, value: string): string;
20
+ };
21
+
22
+ const TOOL_CREATORS = {
23
+ read: createReadTool,
24
+ bash: createBashTool,
25
+ grep: createGrepTool,
26
+ find: createFindTool,
27
+ ls: createLsTool,
28
+ edit: createEditTool,
29
+ write: createWriteTool,
30
+ } satisfies Record<QuietToolName, (cwd: string) => any>;
31
+
32
+ const COLLAPSED_COUNT_LABELS: Partial<Record<QuietToolName, string>> = {
33
+ grep: "matches",
34
+ find: "files",
35
+ ls: "entries",
36
+ };
37
+
38
+ const NO_COLLAPSED_RESULT_TOOLS = new Set<QuietToolName>(["read", "bash"]);
39
+ const COLLAPSED_TAIL_TOOLS = new Set<QuietToolName>(["edit", "write"]);
40
+ const COLLAPSED_TAIL_LINE_LIMIT = 10;
41
+
42
+ const EMPTY_RESULT_MESSAGES: Partial<Record<QuietToolName, string[]>> = {
43
+ grep: ["No matches found"],
44
+ find: ["No files found matching pattern"],
45
+ ls: ["Directory is empty"],
46
+ };
47
+
48
+ const toolCache = new Map<string, Record<QuietToolName, any>>();
49
+
50
+ function createBuiltInTools(cwd: string): Record<QuietToolName, any> {
51
+ return Object.fromEntries(
52
+ (Object.entries(TOOL_CREATORS) as [QuietToolName, (cwd: string) => any][]).map(
53
+ ([name, createTool]) => [name, createTool(cwd)],
54
+ ),
55
+ ) as Record<QuietToolName, any>;
56
+ }
57
+
58
+ function getBuiltInTools(cwd: string): Record<QuietToolName, any> {
59
+ let tools = toolCache.get(cwd);
60
+ if (!tools) {
61
+ tools = createBuiltInTools(cwd);
62
+ toolCache.set(cwd, tools);
63
+ }
64
+ return tools;
65
+ }
66
+
67
+ function shortenPath(path: unknown): string {
68
+ if (typeof path !== "string" || path.length === 0) return "";
69
+ const home = homedir();
70
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
71
+ }
72
+
73
+ function asString(value: unknown, fallback = ""): string {
74
+ return typeof value === "string" && value.length > 0 ? value : fallback;
75
+ }
76
+
77
+ export function countNonEmptyLines(text: string): number {
78
+ return text.split("\n").filter((line) => line.trim().length > 0).length;
79
+ }
80
+
81
+ export function tailLines(text: string, limit: number): string {
82
+ const lines = text.split("\n");
83
+ return lines.slice(Math.max(0, lines.length - limit)).join("\n");
84
+ }
85
+
86
+ export function extractTextContent(result: AgentToolResult<unknown>): string {
87
+ return result.content
88
+ .flatMap((content) => (content.type === "text" ? [content.text] : []))
89
+ .join("\n");
90
+ }
91
+
92
+ function safeText(value: string): string {
93
+ return sanitizeTerminalText(value);
94
+ }
95
+
96
+ function isEmptyResultMessage(toolName: QuietToolName, text: string): boolean {
97
+ const normalized = text.trim();
98
+ return EMPTY_RESULT_MESSAGES[toolName]?.some((message) => normalized.startsWith(message)) ?? false;
99
+ }
100
+
101
+ function isGitCommand(args: Record<string, unknown> | undefined): boolean {
102
+ const command = typeof args?.command === "string" ? args.command.trim() : "";
103
+ return /^(?:env\s+\S+=\S+\s+|command\s+|\w+=\S+\s+)*git(?:\s|$)/.test(command);
104
+ }
105
+
106
+ interface ToolResultFormatOptions {
107
+ expanded: boolean;
108
+ isError?: boolean;
109
+ args?: Record<string, unknown>;
110
+ }
111
+
112
+ export function formatToolResultOutput(
113
+ toolName: QuietToolName,
114
+ result: AgentToolResult<unknown>,
115
+ { expanded, isError = false, args }: ToolResultFormatOptions,
116
+ ): string {
117
+ const text = safeText(extractTextContent(result));
118
+ if (expanded || isError) return text ? `\n${text}` : "";
119
+
120
+ if (toolName === "bash" && isGitCommand(args)) {
121
+ const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
122
+ return tail ? `\n${tail}` : "";
123
+ }
124
+ if (NO_COLLAPSED_RESULT_TOOLS.has(toolName)) return "";
125
+ if (COLLAPSED_TAIL_TOOLS.has(toolName)) {
126
+ const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
127
+ return tail ? `\n${tail}` : "";
128
+ }
129
+ if (isEmptyResultMessage(toolName, text)) return "";
130
+
131
+ const summaryLabel = COLLAPSED_COUNT_LABELS[toolName];
132
+ if (!summaryLabel) return "";
133
+
134
+ const count = countNonEmptyLines(text);
135
+ return count > 0 ? ` → ${count} ${summaryLabel}` : "";
136
+ }
137
+
138
+ function lineRangeSuffix(args: Record<string, unknown>, theme: ThemeLike): string {
139
+ if (args.offset === undefined && args.limit === undefined) return "";
140
+ const startLine = typeof args.offset === "number" ? args.offset : 1;
141
+ const endLine = typeof args.limit === "number" ? startLine + args.limit - 1 : undefined;
142
+ return theme.fg("warning", `:${startLine}${endLine === undefined ? "" : `-${endLine}`}`);
143
+ }
144
+
145
+ function formatToolCall(toolName: QuietToolName, args: Record<string, unknown>, theme: ThemeLike): string {
146
+ switch (toolName) {
147
+ case "read": {
148
+ const path = safeText(shortenPath(args.path) || "...");
149
+ return `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path)}${lineRangeSuffix(args, theme)}`;
150
+ }
151
+ case "bash": {
152
+ const command = safeText(asString(args.command, "..."));
153
+ const timeout = typeof args.timeout === "number" ? theme.fg("muted", ` (timeout ${args.timeout}s)`) : "";
154
+ return `${theme.fg("toolTitle", theme.bold(`$ ${command}`))}${timeout}`;
155
+ }
156
+ case "grep": {
157
+ let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${safeText(asString(args.pattern))}/`)} in ${safeText(shortenPath(args.path) || ".")}`;
158
+ if (typeof args.glob === "string") text += theme.fg("toolOutput", ` (${safeText(args.glob)})`);
159
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
160
+ return text;
161
+ }
162
+ case "find": {
163
+ let text = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", safeText(asString(args.pattern, "*")))} in ${safeText(shortenPath(args.path) || ".")}`;
164
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
165
+ return text;
166
+ }
167
+ case "ls": {
168
+ let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "."))}`;
169
+ if (typeof args.limit === "number") text += theme.fg("toolOutput", ` limit ${args.limit}`);
170
+ return text;
171
+ }
172
+ case "edit":
173
+ return `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "..."))}`;
174
+ case "write": {
175
+ const content = typeof args.content === "string" ? args.content : "";
176
+ const lineInfo = content.length > 0 ? theme.fg("muted", ` (${content.split("\n").length} lines)`) : "";
177
+ return `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", safeText(shortenPath(args.path) || "..."))}${lineInfo}`;
178
+ }
179
+ }
180
+ }
181
+
182
+ function partialLabel(toolName: QuietToolName): string {
183
+ return toolName === "bash" ? "Running..." : `${toolName}...`;
184
+ }
185
+
186
+ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName): void {
187
+ const registrationTool = getBuiltInTools(process.cwd())[toolName];
188
+
189
+ pi.registerTool({
190
+ ...registrationTool,
191
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
192
+ const runtimeTool = getBuiltInTools(ctx.cwd)[toolName];
193
+ return runtimeTool.execute(toolCallId, params, signal, onUpdate, ctx);
194
+ },
195
+ renderCall(args, theme) {
196
+ return new Text(formatToolCall(toolName, args as Record<string, unknown>, theme), 0, 0);
197
+ },
198
+ renderResult(result, options, theme, context) {
199
+ if (options.isPartial) {
200
+ return new Text(theme.fg("warning", partialLabel(toolName)), 0, 0);
201
+ }
202
+ const output = formatToolResultOutput(toolName, result, {
203
+ expanded: options.expanded,
204
+ isError: options.isError,
205
+ args: context.args as Record<string, unknown> | undefined,
206
+ });
207
+ const color = options.expanded ? "toolOutput" : options.isError ? "error" : "muted";
208
+ return new Text(output ? theme.fg(color, output) : "", 0, 0);
209
+ },
210
+ });
211
+ }
212
+
213
+ export default function quietTools(pi: ExtensionAPI): void {
214
+ if (!quietToolsEnabled()) return;
215
+ for (const toolName of Object.keys(TOOL_CREATORS) as QuietToolName[]) {
216
+ registerQuietTool(pi, toolName);
217
+ }
218
+ }
@@ -0,0 +1,17 @@
1
+ export const QUIET_TOOLS_ENV = "GENTLE_PI_QUIET_TOOLS";
2
+ export const PI_PRETTY_SUPPRESSED_TOOL_NAMES = ["read", "bash", "ls", "find", "grep"] as const;
3
+
4
+ export function quietToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
5
+ return env[QUIET_TOOLS_ENV] !== "0";
6
+ }
7
+
8
+ export function mergeDisabledTools(existing: string | undefined, tools: readonly string[]): string {
9
+ const disabled = new Set(
10
+ (existing ?? "")
11
+ .split(",")
12
+ .map((tool) => tool.trim().toLowerCase())
13
+ .filter(Boolean),
14
+ );
15
+ for (const tool of tools) disabled.add(tool);
16
+ return [...disabled].join(",");
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.11.4",
3
+ "version": "0.13.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -121,19 +121,19 @@ test(`getOrchestratorPrompt return value stays within the 10,240 B budget at a r
121
121
  //
122
122
  // Every normative line of the frozen pre-diet fixture is assigned to exactly
123
123
  // one documented disposition: CORE_VERBATIM (byte-identical in the new
124
- // core), or LAZY_VERBATIM (byte-identical in one specific lazy file, while
125
- // core carries a freshly-authored CORE_SUMMARIZED_INTO summary sentence that
126
- // is NOT literal-matched against the original line). Section headings that
127
- // are reused unchanged as the new core's summary heading are CORE_VERBATIM;
128
- // section bodies that are condensed away in core are LAZY_VERBATIM against
129
- // their one target lazy file — never a blanket union across all three.
124
+ // core), LAZY_VERBATIM (byte-identical in one specific lazy file), or OBSOLETE
125
+ // (intentionally absent from every live model-facing asset). Section headings
126
+ // that are reused unchanged as the new core's summary heading are
127
+ // CORE_VERBATIM; section bodies that are condensed away in core are
128
+ // LAZY_VERBATIM against their one target lazy file never a blanket union
129
+ // across all three.
130
130
  // ---------------------------------------------------------------------------
131
131
 
132
132
  type Target = "core" | "delegation" | "memory" | "skills";
133
133
 
134
134
  interface DispositionRange {
135
135
  lines: [number, number];
136
- target: Target;
136
+ target: Target | "obsolete";
137
137
  label: string;
138
138
  }
139
139
 
@@ -162,9 +162,19 @@ const DISPOSITION_MAP: DispositionRange[] = [
162
162
  { lines: [112, 112], target: "core", label: "Delegation Rules heading" },
163
163
  { lines: [114, 114], target: "core", label: "Delegation Rules core question" },
164
164
  {
165
- lines: [116, 181],
165
+ lines: [116, 132],
166
166
  target: "delegation",
167
- label: "Delegation Rules table + Mandatory Triggers + Cost/Context Balance + Canonical Workflows + Review Lens Selection",
167
+ label: "Delegation Rules table + Mandatory Triggers preamble",
168
+ },
169
+ {
170
+ lines: [133, 133],
171
+ target: "obsolete",
172
+ label: "Superseded no-runtime inline exception",
173
+ },
174
+ {
175
+ lines: [134, 181],
176
+ target: "delegation",
177
+ label: "Mandatory Triggers remainder + Cost/Context Balance + Canonical Workflows + Review Lens Selection",
168
178
  },
169
179
  { lines: [183, 191], target: "core", label: "SDD Workflow pointer" },
170
180
  { lines: [193, 193], target: "core", label: "Memory Contract heading" },
@@ -194,11 +204,21 @@ for (const range of DISPOSITION_MAP) {
194
204
  test(
195
205
  `disposition-mapped union: ${range.label} (fixture:${range.lines[0]}-${range.lines[1]}) -> ${range.target}`,
196
206
  () => {
197
- const targetContent = readRealAsset(TARGET_FILE[range.target]);
207
+ const targetContent =
208
+ range.target === "obsolete"
209
+ ? Object.values(TARGET_FILE).map(readRealAsset).join("\n")
210
+ : readRealAsset(TARGET_FILE[range.target]);
198
211
  for (let ln = range.lines[0]; ln <= range.lines[1]; ln++) {
199
212
  const raw = fixtureLines[ln - 1];
200
213
  if (raw === undefined || !isNormativeLine(raw)) continue;
201
214
  const trimmed = raw.trim();
215
+ if (range.target === "obsolete") {
216
+ assert.ok(
217
+ !targetContent.includes(trimmed),
218
+ `obsolete line retained: fixture:${ln} "${trimmed}" remains in a live model-facing asset (section: ${range.label})`,
219
+ );
220
+ continue;
221
+ }
202
222
  assert.ok(
203
223
  targetContent.includes(trimmed),
204
224
  `normative line lost: fixture:${ln} "${trimmed}" not found verbatim in ${TARGET_FILE[range.target]} (disposition: ${range.target}, section: ${range.label})`,
@@ -1,8 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
3
10
  import { dirname, join } from "node:path";
4
11
  import test from "node:test";
5
12
  import { fileURLToPath } from "node:url";
13
+ import { installSddAssets } from "../lib/sdd-preflight.ts";
6
14
 
7
15
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
16
 
@@ -49,6 +57,10 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
49
57
  existsSync(join(PACKAGE_ROOT, "extensions", "pi-pretty.ts")),
50
58
  "gentle-pi must expose pi-pretty through a packaged wrapper extension",
51
59
  );
60
+ assert.ok(
61
+ existsSync(join(PACKAGE_ROOT, "extensions", "quiet-tools.ts")),
62
+ "gentle-pi must expose quiet built-in tool rendering through a packaged extension",
63
+ );
52
64
  assert.ok(
53
65
  !packageJson.bundledDependencies?.includes("@heyhuynhgiabuu/pi-pretty"),
54
66
  "pi-pretty must not be bundled because its native optional dependencies are platform-specific",
@@ -67,6 +79,126 @@ function readAgentFrontmatter(file: string): string {
67
79
  return match[1];
68
80
  }
69
81
 
82
+ function readAgentDefinition(file: string): {
83
+ name: string;
84
+ source: string;
85
+ tools: string[];
86
+ } {
87
+ const source = readFileSync(file, "utf8");
88
+ const frontmatter = readAgentFrontmatter(file);
89
+ const name = frontmatter.match(/^name:\s*(\S+)$/m)?.[1];
90
+ assert.ok(name, `${file} must declare a frontmatter name`);
91
+ const toolsBlock = frontmatter.match(/^tools:\n((?: {2}- [\w-]+\n?)+)/m)?.[1];
92
+ assert.ok(toolsBlock, `${file} must declare a YAML tool list`);
93
+ const tools = [...toolsBlock.matchAll(/^ {2}- ([\w-]+)$/gm)].map(
94
+ (match) => match[1],
95
+ );
96
+
97
+ return { name, source, tools };
98
+ }
99
+
100
+ function readTextContract(source: string, heading: string): string {
101
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
+ const match = source.match(
103
+ new RegExp(`^## ${escapedHeading}\\n[\\s\\S]*?\\n\\x60\\x60\\x60text\\n([\\s\\S]*?)\\n\\x60\\x60\\x60`, "m"),
104
+ );
105
+ assert.ok(match, `${heading} must include a text contract block`);
106
+ return match[1];
107
+ }
108
+
109
+ function contractFields(contract: string, indentation = 0): string[] {
110
+ const prefix = " ".repeat(indentation);
111
+ return contract
112
+ .split("\n")
113
+ .flatMap((line) => {
114
+ const match = line.match(new RegExp(`^${prefix}([a-z_]+):`));
115
+ return match ? [match[1]] : [];
116
+ });
117
+ }
118
+
119
+ function nestedContractFields(contract: string, parent: string): string[] {
120
+ const lines = contract.split("\n");
121
+ const parentIndexes = lines.flatMap((line, index) =>
122
+ line.startsWith(`${parent}:`) ? [index] : [],
123
+ );
124
+ assert.equal(parentIndexes.length, 1, `${parent} must appear exactly once at top level`);
125
+
126
+ const tail = lines.slice(parentIndexes[0] + 1);
127
+ const relativeEnd = tail.findIndex((line) => /^\S/.test(line));
128
+ const nestedBlock = relativeEnd === -1 ? tail : tail.slice(0, relativeEnd);
129
+
130
+ return contractFields(nestedBlock.join("\n"), 2);
131
+ }
132
+
133
+ function readMarkdownSection(source: string, heading: string): string {
134
+ const lines = source.split(/\r?\n/);
135
+ const matches = lines.flatMap((line, index) => {
136
+ const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
137
+ return match?.[2] === heading
138
+ ? [{ index, level: match[1].length }]
139
+ : [];
140
+ });
141
+ assert.equal(matches.length, 1, `Markdown must contain exactly one ${heading} section`);
142
+
143
+ const [{ index: start, level }] = matches;
144
+ const relativeEnd = lines.slice(start + 1).findIndex((line) => {
145
+ const match = line.match(/^(#{1,6})\s+/);
146
+ return match !== null && match[1].length <= level;
147
+ });
148
+ const end = relativeEnd === -1 ? lines.length : start + 1 + relativeEnd;
149
+
150
+ return lines.slice(start + 1, end).join("\n").trim();
151
+ }
152
+
153
+ function assertWorkerFallbackRouting(section: string, sectionName: string): void {
154
+ const boundedWriterPolicy = section.match(
155
+ /For bounded multi-file writes,[\s\S]*?(?=\n\n|\n\s*\d+\.|$)/,
156
+ )?.[0];
157
+ assert.ok(boundedWriterPolicy, `${sectionName} must define bounded writer routing`);
158
+
159
+ const preferred = boundedWriterPolicy.indexOf("`gentle-ai-worker`");
160
+ const configuredFallback = boundedWriterPolicy.indexOf("user-configured `worker`");
161
+ const nativeFallback = boundedWriterPolicy.indexOf("native `Agent`");
162
+
163
+ assert.ok(preferred >= 0, `${sectionName} must reference exact gentle-ai-worker name`);
164
+ assert.ok(
165
+ configuredFallback > preferred,
166
+ `${sectionName} must prefer the package-owned worker before a user-configured worker`,
167
+ );
168
+ assert.ok(
169
+ nativeFallback > configuredFallback,
170
+ `${sectionName} must place native Agent after both named worker definitions`,
171
+ );
172
+ assert.match(
173
+ boundedWriterPolicy,
174
+ /If neither (?:worker )?definition exists[^.]*native `Agent`[^.]*even when `subagent_\*` tools are available\./,
175
+ `${sectionName} must choose native Agent when neither worker definition exists`,
176
+ );
177
+ assert.match(
178
+ section,
179
+ /If no delegation mechanism is available, stop/,
180
+ `${sectionName} must stop when delegation is impossible`,
181
+ );
182
+ }
183
+
184
+ test("Markdown section extraction isolates policy text from sibling sections", () => {
185
+ const markdown = [
186
+ "# Agent",
187
+ "## Context contract",
188
+ "context-only policy",
189
+ "### Context detail",
190
+ "nested context policy",
191
+ "## Tool safety",
192
+ "tool-only policy",
193
+ ].join("\n");
194
+
195
+ const context = readMarkdownSection(markdown, "Context contract");
196
+
197
+ assert.match(context, /context-only policy/);
198
+ assert.match(context, /nested context policy/);
199
+ assert.doesNotMatch(context, /tool-only policy/);
200
+ });
201
+
70
202
  test("packaged agents use YAML list syntax for tool allowlists", () => {
71
203
  const agentsDir = join(PACKAGE_ROOT, "assets", "agents");
72
204
  const agentFiles = readdirSync(agentsDir).flatMap((entry) =>
@@ -96,6 +228,184 @@ test("jd-fix-agent packaged allowlist includes write tools", () => {
96
228
  }
97
229
  });
98
230
 
231
+ test("gentle-ai-worker packages the exact scoped writer contract", () => {
232
+ const agentsDir = join(PACKAGE_ROOT, "assets", "agents");
233
+ const agentPath = join(agentsDir, "gentle-ai-worker.md");
234
+ assert.ok(existsSync(agentPath), "gentle-pi must package gentle-ai-worker.md");
235
+ for (const genericName of ["worker.md", "generic-writer.md"]) {
236
+ assert.ok(
237
+ !existsSync(join(agentsDir, genericName)),
238
+ `the package-owned writer must not use collision-prone ${genericName}`,
239
+ );
240
+ }
241
+
242
+ const { name, source, tools } = readAgentDefinition(agentPath);
243
+ assert.equal(name, "gentle-ai-worker");
244
+ assert.deepEqual(tools, [
245
+ "read",
246
+ "grep",
247
+ "find",
248
+ "edit",
249
+ "write",
250
+ "bash",
251
+ "mem_save",
252
+ ]);
253
+ assert.ok(
254
+ tools.every((tool) => !tool.startsWith("subagent_")),
255
+ "a subagent must not be able to delegate",
256
+ );
257
+ assert.ok(!tools.includes("glob"), "the unsupported glob tool must not return");
258
+
259
+ const interactionContract = readMarkdownSection(source, "Interaction contract");
260
+ assert.doesNotMatch(
261
+ interactionContract,
262
+ /```text/,
263
+ "the interaction section must not define a second normative envelope",
264
+ );
265
+ assert.match(interactionContract, /stop editing/i);
266
+ assert.match(interactionContract, /full schema in the Return contract/);
267
+ assert.match(interactionContract, /`status: interaction_required`/);
268
+ assert.match(interactionContract, /nested `interaction_required` payload/);
269
+
270
+ const returnContract = readTextContract(source, "Return contract");
271
+ assert.deepEqual(contractFields(returnContract), [
272
+ "status",
273
+ "summary",
274
+ "files_changed",
275
+ "tdd_evidence",
276
+ "validation",
277
+ "risks",
278
+ "review_focus",
279
+ "skill_resolution",
280
+ "interaction_required",
281
+ ]);
282
+ assert.deepEqual(nestedContractFields(returnContract, "interaction_required"), [
283
+ "question",
284
+ "reason",
285
+ "options",
286
+ "unblock_response",
287
+ ]);
288
+ assert.match(
289
+ returnContract,
290
+ /skill_resolution: paths-injected \| paths-invalid \| none/,
291
+ );
292
+ assert.equal(
293
+ (source.match(/```text/g) ?? []).length,
294
+ 1,
295
+ "the Return contract must be the single authoritative full handoff schema",
296
+ );
297
+ assert.doesNotMatch(source, /fallback-(?:registry|path)/);
298
+
299
+ const returnContractSection = readMarkdownSection(source, "Return contract");
300
+ assert.match(
301
+ returnContractSection,
302
+ /Use `skill_resolution: paths-invalid` only when the parent injected one or more exact skill paths and any supplied path cannot be read/,
303
+ );
304
+ assert.match(
305
+ returnContractSection,
306
+ /With `skill_resolution: paths-invalid`, keep `status: blocked`/,
307
+ );
308
+
309
+ const contextContract = readMarkdownSection(source, "Context contract");
310
+ assert.match(contextContract, /pre-existing untracked targets explicitly listed by the parent/);
311
+ assert.match(contextContract, /new files required by the delegated task/);
312
+
313
+ const implementationRules = readMarkdownSection(source, "Implementation rules");
314
+ assert.match(implementationRules, /`blocked` only for a non-human technical blocker/);
315
+
316
+ const toolSafety = readMarkdownSection(source, "Tool safety");
317
+ assert.match(toolSafety, /sensitive files/);
318
+ assert.match(toolSafety, /stage, commit, push, publish/);
319
+
320
+ const memorySafety = readMarkdownSection(source, "Memory safety");
321
+ assert.match(memorySafety, /secrets, credentials, personal data/);
322
+ assert.match(memorySafety, /raw untrusted repository/);
323
+
324
+ const testDiscipline = readMarkdownSection(source, "Test discipline");
325
+ assert.match(testDiscipline, /Strict TDD is active/);
326
+ assert.match(testDiscipline, /not active/);
327
+ assert.match(
328
+ testDiscipline,
329
+ /Broad suites, builds, formatters, or linters may run only when explicitly authorized by the parent\./,
330
+ );
331
+ assert.match(testDiscipline, /Keep every command exact and verify its scope before execution\./);
332
+ assert.doesNotMatch(testDiscipline, /clearly required by the repository contract/);
333
+ });
334
+
335
+ test("installSddAssets installs gentle-ai-worker with a loader-compatible scoped identity", () => {
336
+ const temporaryAgentHome = mkdtempSync(join(tmpdir(), "gentle-pi-agent-home-"));
337
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
338
+
339
+ try {
340
+ process.env.GENTLE_PI_AGENT_HOME = temporaryAgentHome;
341
+ installSddAssets(PACKAGE_ROOT, true);
342
+
343
+ const installedAgentsDir = join(temporaryAgentHome, "agents");
344
+ const installedAgentPath = join(installedAgentsDir, "gentle-ai-worker.md");
345
+ assert.ok(existsSync(installedAgentPath), "the production installer must install gentle-ai-worker.md");
346
+ for (const genericName of ["worker.md", "generic-writer.md"]) {
347
+ assert.ok(
348
+ !existsSync(join(installedAgentsDir, genericName)),
349
+ `the installer must not create collision-prone ${genericName}`,
350
+ );
351
+ }
352
+
353
+ const { name, source, tools } = readAgentDefinition(installedAgentPath);
354
+ const normalizedRuntimeIdentity = name.trim().toLowerCase();
355
+ assert.equal(normalizedRuntimeIdentity, "gentle-ai-worker");
356
+ assert.deepEqual(tools, [
357
+ "read",
358
+ "grep",
359
+ "find",
360
+ "edit",
361
+ "write",
362
+ "bash",
363
+ "mem_save",
364
+ ]);
365
+ assert.doesNotMatch(
366
+ readAgentFrontmatter(installedAgentPath),
367
+ /^package\s*:/m,
368
+ "package frontmatter must not alter external loader identity",
369
+ );
370
+ assert.doesNotMatch(source, /^name:\s*(?:worker|generic-writer)$/m);
371
+ } finally {
372
+ if (previousAgentHome === undefined) {
373
+ delete process.env.GENTLE_PI_AGENT_HOME;
374
+ } else {
375
+ process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
376
+ }
377
+ rmSync(temporaryAgentHome, { recursive: true, force: true });
378
+ }
379
+
380
+ assert.equal(process.env.GENTLE_PI_AGENT_HOME, previousAgentHome);
381
+ assert.ok(
382
+ !existsSync(temporaryAgentHome),
383
+ "the integration test must delete only its temporary agent home",
384
+ );
385
+ });
386
+
387
+ test("bounded implementation routing uses the same explicit fallback in both policy sections", () => {
388
+ const routing = readFileSync(
389
+ join(PACKAGE_ROOT, "assets", "orchestrator-delegation.md"),
390
+ "utf8",
391
+ );
392
+ const simpleDelegation = readMarkdownSection(routing, "2. Simple Delegation");
393
+ const mandatoryDelegation = readMarkdownSection(routing, "Mandatory Delegation Triggers");
394
+
395
+ assertWorkerFallbackRouting(simpleDelegation, "Simple Delegation");
396
+ assertWorkerFallbackRouting(mandatoryDelegation, "Mandatory Delegation Triggers");
397
+ assert.doesNotMatch(
398
+ routing,
399
+ /non-normative compatibility quotation|former wording is retained|no-runtime inline exception|superseded by the stop requirement/,
400
+ "model-facing routing must not retain contradictory dead prose",
401
+ );
402
+ assert.doesNotMatch(
403
+ routing,
404
+ /`generic-writer`/,
405
+ "routing must not revive the collision-prone generic package name",
406
+ );
407
+ });
408
+
99
409
  test("pi-pretty wrapper uses real package path resolution for pnpm symlink installs", () => {
100
410
  const wrapper = readFileSync(
101
411
  join(PACKAGE_ROOT, "extensions", "pi-pretty.ts"),
@@ -105,4 +415,6 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
105
415
  assert.match(wrapper, /realpathSync/);
106
416
  assert.match(wrapper, /createRequire/);
107
417
  assert.match(wrapper, /@heyhuynhgiabuu\/pi-pretty/);
418
+ assert.match(wrapper, /PI_PRETTY_SUPPRESSED_TOOL_NAMES/);
419
+ assert.match(wrapper, /quietToolsEnabled/);
108
420
  });
@@ -0,0 +1,249 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import piPretty from "../extensions/pi-pretty.ts";
4
+ import quietTools, {
5
+ countNonEmptyLines,
6
+ extractTextContent,
7
+ formatToolResultOutput,
8
+ tailLines,
9
+ } from "../extensions/quiet-tools.ts";
10
+
11
+ const passthroughTheme = {
12
+ bold(value: string) {
13
+ return value;
14
+ },
15
+ fg(_color: string, value: string) {
16
+ return value;
17
+ },
18
+ };
19
+
20
+ function renderToString(component: { render(width: number): string[] }): string {
21
+ return component.render(120).join("\n");
22
+ }
23
+
24
+ function textResult(text: string) {
25
+ return {
26
+ content: [{ type: "text", text }],
27
+ };
28
+ }
29
+
30
+ function createPi(options: { throwOnToolConflict?: boolean } = {}) {
31
+ const tools = new Map<string, any>();
32
+ const commands = new Map<string, any>();
33
+ const hooks = new Map<string, any[]>();
34
+ return {
35
+ tools,
36
+ pi: {
37
+ registerTool(tool: any) {
38
+ if (options.throwOnToolConflict && tools.has(tool.name)) {
39
+ throw new Error(`Tool ${tool.name} already registered`);
40
+ }
41
+ tools.set(tool.name, tool);
42
+ },
43
+ registerCommand(name: string, command: any) {
44
+ commands.set(name, command);
45
+ },
46
+ on(name: string, handler: any) {
47
+ hooks.set(name, [...(hooks.get(name) ?? []), handler]);
48
+ },
49
+ },
50
+ };
51
+ }
52
+
53
+ function createSdkTool(name: string) {
54
+ return {
55
+ name,
56
+ label: name,
57
+ description: `${name} tool`,
58
+ parameters: { type: "object", properties: {} },
59
+ execute: async () => textResult(`${name} result`),
60
+ };
61
+ }
62
+
63
+ const fakePiPrettyDeps = {
64
+ sdk: {
65
+ createReadTool: () => createSdkTool("read"),
66
+ createBashTool: () => createSdkTool("bash"),
67
+ createLsTool: () => createSdkTool("ls"),
68
+ createFindTool: () => createSdkTool("find"),
69
+ createGrepTool: () => createSdkTool("grep"),
70
+ },
71
+ };
72
+
73
+ function withEnv<T>(updates: Record<string, string | undefined>, run: () => T): T {
74
+ const previous = Object.fromEntries(Object.keys(updates).map((key) => [key, process.env[key]]));
75
+ try {
76
+ for (const [key, value] of Object.entries(updates)) {
77
+ if (value === undefined) delete process.env[key];
78
+ else process.env[key] = value;
79
+ }
80
+ return run();
81
+ } finally {
82
+ for (const [key, value] of Object.entries(previous)) {
83
+ if (value === undefined) delete process.env[key];
84
+ else process.env[key] = value;
85
+ }
86
+ }
87
+ }
88
+
89
+ async function withEnvAsync<T>(updates: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
90
+ const previous = Object.fromEntries(Object.keys(updates).map((key) => [key, process.env[key]]));
91
+ try {
92
+ for (const [key, value] of Object.entries(updates)) {
93
+ if (value === undefined) delete process.env[key];
94
+ else process.env[key] = value;
95
+ }
96
+ return await run();
97
+ } finally {
98
+ for (const [key, value] of Object.entries(previous)) {
99
+ if (value === undefined) delete process.env[key];
100
+ else process.env[key] = value;
101
+ }
102
+ }
103
+ }
104
+
105
+ test("quiet tool rendering registers noisy built-in tools", () => {
106
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => {
107
+ const { pi, tools } = createPi();
108
+
109
+ quietTools(pi as any);
110
+
111
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
112
+ const tool = tools.get(toolName);
113
+ assert.ok(tool, `missing quiet renderer for ${toolName}`);
114
+ assert.equal(typeof tool.execute, "function", `${toolName} must delegate execution`);
115
+ assert.ok(tool.parameters, `${toolName} must preserve built-in parameters`);
116
+ }
117
+ });
118
+ });
119
+
120
+ test("quiet tool rendering can be disabled by env", () => {
121
+ withEnv({ GENTLE_PI_QUIET_TOOLS: "0" }, () => {
122
+ const { pi, tools } = createPi();
123
+
124
+ quietTools(pi as any);
125
+
126
+ assert.equal(tools.size, 0);
127
+ });
128
+ });
129
+
130
+ test("pi-pretty suppresses overlapping tools before quiet tools register", async () => {
131
+ await withEnvAsync(
132
+ { GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: "multi_grep" },
133
+ async () => {
134
+ const { pi, tools } = createPi({ throwOnToolConflict: true });
135
+
136
+ await piPretty(pi as any, fakePiPrettyDeps as any);
137
+ quietTools(pi as any);
138
+
139
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
140
+ assert.ok(tools.has(toolName), `missing quiet tool ${toolName}`);
141
+ }
142
+ assert.equal(process.env.PRETTY_DISABLE_TOOLS, "multi_grep,read,bash,ls,find,grep");
143
+ },
144
+ );
145
+ });
146
+
147
+ test("pi-pretty suppression is skipped when quiet tools are disabled", async () => {
148
+ await withEnvAsync(
149
+ { GENTLE_PI_QUIET_TOOLS: "0", PRETTY_DISABLE_TOOLS: undefined },
150
+ async () => {
151
+ const { pi, tools } = createPi();
152
+
153
+ await piPretty(pi as any, fakePiPrettyDeps as any);
154
+ quietTools(pi as any);
155
+
156
+ for (const toolName of ["read", "bash", "grep", "find", "ls"]) {
157
+ assert.ok(tools.has(toolName), `pi-pretty should keep ${toolName} when quiet tools are disabled`);
158
+ }
159
+ assert.equal(process.env.PRETTY_DISABLE_TOOLS, undefined);
160
+ },
161
+ );
162
+ });
163
+
164
+ test("quiet tool rendering hides noisy result bodies while collapsed and restores them when expanded", () => {
165
+ const { pi, tools } = createPi();
166
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
167
+
168
+ const cases = [
169
+ { tool: "read", text: "first line\nsecond line", hidden: "first line", expanded: "second line" },
170
+ { tool: "bash", text: "stdout line\nstderr line", hidden: "stdout line", expanded: "stderr line" },
171
+ { tool: "grep", text: "src/a.ts:1:match\nsrc/b.ts:2:match", hidden: "src/a.ts", expanded: "src/b.ts" },
172
+ { tool: "find", text: "src/a.ts\nsrc/b.ts", hidden: "src/a.ts", expanded: "src/b.ts" },
173
+ { tool: "ls", text: "file-a.ts\nfile-b.ts", hidden: "file-a.ts", expanded: "file-b.ts" },
174
+ ];
175
+
176
+ for (const entry of cases) {
177
+ const tool = tools.get(entry.tool);
178
+ const collapsed = renderToString(
179
+ tool.renderResult(textResult(entry.text), { expanded: false, isPartial: false }, passthroughTheme, {}),
180
+ );
181
+ const expanded = renderToString(
182
+ tool.renderResult(textResult(entry.text), { expanded: true, isPartial: false }, passthroughTheme, {}),
183
+ );
184
+
185
+ assert.doesNotMatch(collapsed, new RegExp(entry.hidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${entry.tool} collapsed output must not include result body`);
186
+ assert.match(expanded, new RegExp(entry.expanded.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${entry.tool} expanded output must include full result body`);
187
+ }
188
+ });
189
+
190
+ test("quiet tool rendering keeps compact collapsed summaries for search and listing tools", () => {
191
+ assert.equal(countNonEmptyLines("a\n\n b \n"), 2);
192
+ assert.equal(extractTextContent(textResult("alpha\nbeta") as any), "alpha\nbeta");
193
+ assert.equal(formatToolResultOutput("grep", textResult("a\nb\n") as any, { expanded: false }), " → 2 matches");
194
+ assert.equal(formatToolResultOutput("find", textResult("a\nb\n") as any, { expanded: false }), " → 2 files");
195
+ assert.equal(formatToolResultOutput("ls", textResult("a\nb\n") as any, { expanded: false }), " → 2 entries");
196
+ assert.equal(formatToolResultOutput("grep", textResult("No matches found") as any, { expanded: false }), "");
197
+ assert.equal(formatToolResultOutput("find", textResult("No files found matching pattern") as any, { expanded: false }), "");
198
+ assert.equal(formatToolResultOutput("ls", textResult("Directory is empty") as any, { expanded: false }), "");
199
+ assert.equal(formatToolResultOutput("read", textResult("a\nb\n") as any, { expanded: false }), "");
200
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false }), "");
201
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "git diff" } }), "\na\nb\n");
202
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "git -C repo status" } }), "\na\nb\n");
203
+ assert.equal(formatToolResultOutput("bash", textResult("a\nb\n") as any, { expanded: false, args: { command: "echo git diff" } }), "");
204
+ assert.equal(formatToolResultOutput("edit", textResult("updated") as any, { expanded: false }), "\nupdated");
205
+ assert.equal(formatToolResultOutput("write", textResult("wrote") as any, { expanded: false }), "\nwrote");
206
+ assert.equal(formatToolResultOutput("grep", textResult("a\nb\n") as any, { expanded: true }), "\na\nb\n");
207
+ assert.equal(formatToolResultOutput("read", textResult("ENOENT: missing file") as any, { expanded: false, isError: true }), "\nENOENT: missing file");
208
+ });
209
+
210
+ test("quiet tool rendering keeps collapsed git bash result tails", () => {
211
+ const text = Array.from({ length: 12 }, (_, index) => `git line ${index + 1}`).join("\n");
212
+
213
+ assert.equal(formatToolResultOutput("bash", textResult(text) as any, { expanded: false, args: { command: "git diff" } }), `\n${tailLines(text, 10)}`);
214
+ assert.equal(formatToolResultOutput("bash", textResult(text) as any, { expanded: false, args: { command: "git status --short" } }), `\n${tailLines(text, 10)}`);
215
+ });
216
+
217
+ test("quiet tool rendering keeps collapsed edit and write result tails", () => {
218
+ const text = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n");
219
+
220
+ assert.equal(tailLines(text, 10), Array.from({ length: 10 }, (_, index) => `line ${index + 3}`).join("\n"));
221
+ assert.equal(formatToolResultOutput("edit", textResult(text) as any, { expanded: false }), `\n${tailLines(text, 10)}`);
222
+ assert.equal(formatToolResultOutput("write", textResult(text) as any, { expanded: false }), `\n${tailLines(text, 10)}`);
223
+ });
224
+
225
+ test("quiet tool rendering sanitizes collapsed output and call rows", () => {
226
+ const { pi, tools } = createPi();
227
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
228
+
229
+ const collapsed = renderToString(
230
+ tools.get("write").renderResult(textResult("safe\x1b[31mred\x1b[0m"), { expanded: false, isPartial: false }, passthroughTheme, {}),
231
+ );
232
+ const call = renderToString(tools.get("bash").renderCall({ command: "echo \x1b[31mred\x1b[0m" }, passthroughTheme, {}));
233
+
234
+ assert.equal(collapsed.replace(/[ \t]+$/gm, ""), "\nsafered");
235
+ assert.equal(call.trimEnd(), "$ echo red");
236
+ });
237
+
238
+ test("quiet tool rendering call rows show tool calls without result output", () => {
239
+ const { pi, tools } = createPi();
240
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
241
+
242
+ const readCall = renderToString(tools.get("read").renderCall({ path: "/tmp/example.ts", offset: 2, limit: 3 }, passthroughTheme, {}));
243
+ const bashCall = renderToString(tools.get("bash").renderCall({ command: "printf noisy", timeout: 5 }, passthroughTheme, {}));
244
+ const grepCall = renderToString(tools.get("grep").renderCall({ pattern: "needle", path: "src", glob: "*.ts" }, passthroughTheme, {}));
245
+
246
+ assert.match(readCall, /read .*example\.ts:2-4/);
247
+ assert.match(bashCall, /\$ printf noisy \(timeout 5s\)/);
248
+ assert.match(grepCall, /grep \/needle\/ in src \(\*\.ts\)/);
249
+ });
@@ -12,6 +12,7 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
12
12
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
13
  const EXTENSIONS = [
14
14
  "extensions/gentle-ai.ts",
15
+ "extensions/quiet-tools.ts",
15
16
  "extensions/skill-registry.ts",
16
17
  "extensions/sdd-init.ts",
17
18
  "extensions/startup-banner.ts",
@@ -59,6 +60,7 @@ function createPi() {
59
60
  const hooks = new Map();
60
61
  const commands = new Map();
61
62
  const flags = new Map();
63
+ const tools = new Map();
62
64
  const flagValues = new Map([["no-skill-registry", true]]);
63
65
  let activeTools = ["read", "bash", "edit", "write"];
64
66
 
@@ -74,6 +76,9 @@ function createPi() {
74
76
  registerFlag(name, definition) {
75
77
  flags.set(name, definition);
76
78
  },
79
+ registerTool(definition) {
80
+ tools.set(definition.name, definition);
81
+ },
77
82
  getFlag(name) {
78
83
  return flagValues.get(name) ?? false;
79
84
  },
@@ -100,7 +105,7 @@ function createPi() {
100
105
  },
101
106
  };
102
107
 
103
- return { pi, hooks, commands, flags };
108
+ return { pi, hooks, commands, flags, tools };
104
109
  }
105
110
 
106
111
  function createUi() {
@@ -170,7 +175,7 @@ async function run() {
170
175
  process.env.GENTLE_PI_TEST_ASSETS_DIR = ambientTestAssetsDir;
171
176
  const globalModelsPath = join(globalConfigHome, "models.json");
172
177
  const globalSubagentsPath = join(globalAgentHome, "subagents.json");
173
- const { pi, hooks, commands, flags } = createPi();
178
+ const { pi, hooks, commands, flags, tools } = createPi();
174
179
  await loadExtensions(pi);
175
180
 
176
181
  for (const name of EXPECTED_COMMANDS) {
@@ -185,6 +190,9 @@ async function run() {
185
190
  assert.ok(hooks.has("input"), "missing input hook");
186
191
  assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
187
192
  assert.ok(hooks.has("tool_call"), "missing tool_call hook");
193
+ for (const toolName of ["read", "bash", "grep", "find", "ls", "edit", "write"]) {
194
+ assert.ok(tools.has(toolName), `missing quiet built-in tool renderer ${toolName}`);
195
+ }
188
196
 
189
197
  for (const entry of await readdir(join(ROOT, "assets", "agents"))) {
190
198
  if (!entry.endsWith(".md")) continue;