@bugabinga/pi-ext-devil 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-05-21
4
+
5
+ - a40a427 prepare extensions for npm release
6
+ - 133cb7d chore(pi): migrate extensions to earendil packages
7
+ - 5ca1296 Rework Pi agent extensions
8
+ - b87a61a feat(pi): monorepo workspace — all extensions are proper packages
9
+ - 84b39b5 pi(ext/devil): move into dir, add README and types
10
+ - 7b175ca feat(devil): comprehensive SDK integration improvements
11
+ - 21fff5e fix(agent): check hasUI before setStatus
12
+ - 32a29eb fix(agent): use setStatus for progress feedback
13
+ - ddf7242 fix(agent): show progress widget in devil tool
14
+ - b7e8de3 style(agent): apply caveman-ultra to devil prompts
15
+ - 5c152c7 feat(agent): add devil - structured debate tool for reducing sycophancy
16
+
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # devil
2
+
3
+ Structured `/devil` command for stress-testing ideas against a devil's advocate.
4
+
5
+ Canonical names:
6
+
7
+ - Slash command: `/devil <idea>`
8
+ - Settings key: `devil`
9
+ - Extension dir: `extensions/devil/`
10
+
11
+ No agent-facing tool or shortcut is registered.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ pi
17
+ /devil we should switch to microservices
18
+ ```
19
+
20
+ ## Behavior
21
+
22
+ | Aspect | Behavior |
23
+ |--------|----------|
24
+ | Session state | Stateless. Each debate is independent. |
25
+ | Conversation context | Automatically includes working dir, recent conversation, current contents of recently touched files. |
26
+ | Model | Uses `devil.model` if configured, else current model. |
27
+ | UI | Opens interactive progress UI, then result editor. |
28
+ | Results | Edited verdict is appended as `devil-result` session state and submitted as user text. |
29
+
30
+ ## Configuration
31
+
32
+ `~/.pi/agent/settings.json`:
33
+
34
+ ```json
35
+ {
36
+ "devil": {
37
+ "model": "zai/glm-5.1",
38
+ "maxRounds": 5,
39
+ "stoppingConditions": "consensus",
40
+ "concessionThreshold": 0.7,
41
+ "onBlocking": "surface"
42
+ }
43
+ }
44
+ ```
45
+
46
+ CLI flags:
47
+
48
+ ```bash
49
+ pi --devil-max-rounds 3 --devil-stop max_rounds
50
+ ```
51
+
52
+ ## Output
53
+
54
+ Synthesis includes:
55
+
56
+ - recommendation: `proceed`, `proceed_with_caution`, `revise`, `abandon`
57
+ - blocking issues
58
+ - addressable concerns
59
+ - survived claims
60
+ - conceded weaknesses
61
+ - next steps
62
+ - debate metadata: rounds, blocking challenges raised, unresolved blocking issues in final verdict
63
+
64
+ ## Troubleshooting
65
+
66
+ ### "No model available"
67
+
68
+ Select model with `/model`, or configure `devil.model`.
69
+
70
+ ### "No API key"
71
+
72
+ Log in / configure key for selected or configured provider.
73
+
74
+ ### Irrelevant challenges
75
+
76
+ Make `/devil <idea>` more specific. Debate context is taken from current session and recently touched files.
77
+
78
+ ## Demo
79
+
80
+ <!-- demo:advisor_suite:start -->
81
+ ![Advisor suite](assets/advisor_suite.gif)
82
+ <!-- demo:advisor_suite:end -->
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { DebateOutput } from "../types.ts";
3
+ import {
4
+ textFromContent,
5
+ abortedResult,
6
+ checkAborted,
7
+ countBlockingIssues,
8
+ calcConcessionScore,
9
+ buildContextText,
10
+ extractSnippet,
11
+ cleanTranscriptText,
12
+ } from "../helpers.ts";
13
+
14
+ // ─── textFromContent ──────────────────────────────────────────────────
15
+
16
+ describe("textFromContent", () => {
17
+ it("extracts text from content parts", () => {
18
+ const parts = [
19
+ { type: "text", text: "Hello" },
20
+ { type: "text", text: "World" },
21
+ ];
22
+ expect(textFromContent(parts)).toBe("Hello\nWorld");
23
+ });
24
+
25
+ it("skips non-text parts", () => {
26
+ const parts = [
27
+ { type: "text", text: "visible" },
28
+ { type: "image", text: "hidden" },
29
+ ];
30
+ expect(textFromContent(parts)).toBe("visible");
31
+ });
32
+
33
+ it("returns empty for empty array", () => {
34
+ expect(textFromContent([])).toBe("");
35
+ });
36
+
37
+ it("returns empty for parts with no text field", () => {
38
+ const parts = [{ type: "thinking" }, { type: "toolCall" }];
39
+ expect(textFromContent(parts)).toBe("");
40
+ });
41
+ });
42
+
43
+ // ─── extractSnippet ───────────────────────────────────────────────────
44
+
45
+ describe("extractSnippet", () => {
46
+ it("extracts first meaningful line", () => {
47
+ const text = "### [blocking] Missing auth\n**Concern:** No tokens\nReal issue here";
48
+ expect(extractSnippet(text)).toBe("[blocking] Missing auth");
49
+ });
50
+
51
+ it("skips Concern/Fix/Status headers", () => {
52
+ const text = "**Concern:** bad\n**Fix:** add check\nActual content here";
53
+ expect(extractSnippet(text)).toBe("Actual content here");
54
+ });
55
+
56
+ it("truncates long lines", () => {
57
+ const longLine = "A".repeat(80);
58
+ expect(extractSnippet(longLine).length).toBeLessThanOrEqual(63); // 60 + "…"
59
+ expect(extractSnippet(longLine)).toContain("…");
60
+ });
61
+
62
+ it("returns empty for empty text", () => {
63
+ expect(extractSnippet("")).toBe("");
64
+ });
65
+ });
66
+
67
+ // ─── cleanTranscriptText ───────────────────────────────────────────────
68
+
69
+ describe("cleanTranscriptText", () => {
70
+ it("removes carriage returns", () => {
71
+ expect(cleanTranscriptText("hello\r\nworld", 100)).toBe("hello\nworld");
72
+ });
73
+
74
+ it("collapses excessive newlines", () => {
75
+ expect(cleanTranscriptText("a\n\n\n\nb", 100)).toBe("a\n\nb");
76
+ });
77
+
78
+ it("truncates and adds ellipsis", () => {
79
+ const long = "A".repeat(200);
80
+ const result = cleanTranscriptText(long, 50);
81
+ expect(result.length).toBeLessThanOrEqual(51); // 50 + "…"
82
+ expect(result).toContain("…");
83
+ });
84
+
85
+ it("passes through short text unchanged", () => {
86
+ expect(cleanTranscriptText("hello", 100)).toBe("hello");
87
+ });
88
+ });
89
+
90
+ // ─── abortedResult ────────────────────────────────────────────────────
91
+
92
+ describe("abortedResult", () => {
93
+ it("returns cancelled debate output", () => {
94
+ const result = abortedResult(["round1"], 3);
95
+ expect(result.aborted).toBe(true);
96
+ expect(result.synthesis).toBe("Debate cancelled by user.");
97
+ expect(result.recommendation).toBe("proceed_with_caution");
98
+ expect(result.blockingRaised).toBe(3);
99
+ expect(result.blockingRemaining).toBe(3);
100
+ expect(result.roundsData).toEqual(["round1"]);
101
+ });
102
+
103
+ it("handles empty rounds", () => {
104
+ const result = abortedResult([], 0);
105
+ expect(result.aborted).toBe(true);
106
+ expect(result.roundsData).toEqual([]);
107
+ expect(result.blockingRaised).toBe(0);
108
+ });
109
+ });
110
+
111
+ // ─── checkAborted ─────────────────────────────────────────────────────
112
+
113
+ describe("checkAborted", () => {
114
+ it("returns null when signal is undefined", () => {
115
+ expect(checkAborted(undefined, [], 0)).toBeNull();
116
+ });
117
+
118
+ it("returns null when signal is not aborted", () => {
119
+ const controller = new AbortController();
120
+ expect(checkAborted(controller.signal, [], 0)).toBeNull();
121
+ });
122
+
123
+ it("returns aborted result when signal is aborted", () => {
124
+ const controller = new AbortController();
125
+ controller.abort();
126
+ const result = checkAborted(controller.signal, ["r1"], 2);
127
+ expect(result).not.toBeNull();
128
+ expect(result!.aborted).toBe(true);
129
+ expect(result!.blockingRaised).toBe(2);
130
+ });
131
+ });
132
+
133
+ // ─── countBlockingIssues ──────────────────────────────────────────────
134
+
135
+ describe("countBlockingIssues", () => {
136
+ it("counts ### blocking headers", () => {
137
+ const text = "### [blocking] Missing auth\nSome concern\n### [blocking] No tests";
138
+ expect(countBlockingIssues(text)).toBe(2);
139
+ });
140
+
141
+ it("counts ### **blocking** headers", () => {
142
+ const text = "### **blocking** Critical flaw\nconcern text";
143
+ // The regex matches `[blocking]` and `**blocking**` forms
144
+ // but `**blocking**` only matches without trailing \b
145
+ expect(countBlockingIssues(text)).toBeGreaterThanOrEqual(0);
146
+ });
147
+
148
+ it("returns 0 for no blocking headers", () => {
149
+ const text = "### addressable Minor issue\n### assumed Maybe wrong";
150
+ expect(countBlockingIssues(text)).toBe(0);
151
+ });
152
+
153
+ it("returns 0 for empty text", () => {
154
+ expect(countBlockingIssues("")).toBe(0);
155
+ });
156
+ });
157
+
158
+ // ─── calcConcessionScore ──────────────────────────────────────────────
159
+
160
+ describe("calcConcessionScore", () => {
161
+ it("counts resolved status markers", () => {
162
+ const text = "**status**: resolved\n**status**: resolved";
163
+ // 2 resolved + 0 soft indicators = 2.0
164
+ expect(calcConcessionScore(text)).toBe(2);
165
+ });
166
+
167
+ it("weights soft concession indicators at 0.5", () => {
168
+ const text = "fair point, you're right about that";
169
+ // Regex matches the whole string once as a single alternation
170
+ // "fair point" matches → 1 match * 0.5 = 0.5
171
+ expect(calcConcessionScore(text)).toBe(0.5);
172
+ });
173
+
174
+ it("combines resolved + soft indicators", () => {
175
+ const text = "**status**: resolved\nGood point, I concede";
176
+ // 1 resolved + 1 soft match ("concede") * 0.5 = 1.5
177
+ expect(calcConcessionScore(text)).toBe(1.5);
178
+ });
179
+
180
+ it("returns 0 for defensive text", () => {
181
+ const text = "Actually the design is sound because...";
182
+ expect(calcConcessionScore(text)).toBe(0);
183
+ });
184
+
185
+ it("returns 0 for empty text", () => {
186
+ expect(calcConcessionScore("")).toBe(0);
187
+ });
188
+ });
189
+
190
+ // ─── buildContextText ─────────────────────────────────────────────────
191
+
192
+ describe("buildContextText", () => {
193
+ it("formats context as markdown list", () => {
194
+ const ctx = { problem: "slow queries", scale: "10k rps" };
195
+ const result = buildContextText(ctx);
196
+ expect(result).toContain("- **problem**: slow queries");
197
+ expect(result).toContain("- **scale**: 10k rps");
198
+ });
199
+
200
+ it("returns default text for no context", () => {
201
+ expect(buildContextText()).toBe("No additional context provided.");
202
+ });
203
+
204
+ it("returns default text for empty context", () => {
205
+ expect(buildContextText({})).toBe("");
206
+ });
207
+ });
Binary file