gentle-pi 2.5.0 → 2.6.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/README.md +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -58,6 +58,12 @@ test("renderAgentsCard draws columns for agent, task, and model · tokens · cos
|
|
|
58
58
|
assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
test("renderAgentsCard renders singleton elapsed time only on its task row", () => {
|
|
62
|
+
const lines = renderAgentsCard([task({})], plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
63
|
+
assert.equal(lines.join("\n").match(/4s/g)?.length, 1);
|
|
64
|
+
assert.match(lines[1], /4s/);
|
|
65
|
+
});
|
|
66
|
+
|
|
61
67
|
test("renderAgentsCard keeps every task on one line, clipping long labels, and drops the task column when the card is narrow", () => {
|
|
62
68
|
const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
|
|
63
69
|
const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
@@ -65,7 +71,7 @@ test("renderAgentsCard keeps every task on one line, clipping long labels, and d
|
|
|
65
71
|
assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
|
|
66
72
|
const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
|
|
67
73
|
assert.equal(narrow.length, 3);
|
|
68
|
-
assert.match(narrow[1], /^│ ◐ sdd-explore +
|
|
74
|
+
assert.match(narrow[1], /^│ ◐ sdd-explore +claude-sonnet-5 +│$/);
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
test("renderAgentsCard shows questions and failures in place of the task, and collapses to the first row", () => {
|
|
@@ -85,6 +91,30 @@ test("renderAgentsCard shows questions and failures in place of the task, and co
|
|
|
85
91
|
assert.match(collapsed[1], /^│ \? sdd-explore asked: Delete\?/);
|
|
86
92
|
});
|
|
87
93
|
|
|
94
|
+
test("agent model and effort outrank usage at sidebar widths without inventing unknown values", () => {
|
|
95
|
+
for (const width of [32, 44, 60, 100]) {
|
|
96
|
+
const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
|
|
97
|
+
assert.equal(lines.length, 3);
|
|
98
|
+
assert.match(lines[1], /worker/);
|
|
99
|
+
assert.match(lines[1], /gpt-5 · high/);
|
|
100
|
+
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
101
|
+
if (width <= 44) assert.doesNotMatch(lines[1], /34k|\$0\.27|4s/);
|
|
102
|
+
}
|
|
103
|
+
for (const width of [0, 1, 2, 3, 4, 8, 16, 24]) {
|
|
104
|
+
const lines = renderAgentsCard([task({ agent: "界worker", thinking: "xhigh" })], plainTheme, width, 5000, { collapsed: false });
|
|
105
|
+
assert.ok(lines.length <= 4, "narrow metadata gets at most one dedicated row");
|
|
106
|
+
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
107
|
+
}
|
|
108
|
+
const sidebar = renderAgentsCard([task({ agent: "gentle-ai-worker", model: "openai/gpt-5.6", thinking: "high" })], plainTheme, 32, 5000, { collapsed: false });
|
|
109
|
+
assert.match(sidebar.join("\n"), /gentle-ai-worker/);
|
|
110
|
+
assert.match(sidebar.join("\n"), /gpt-5\.6 · high/);
|
|
111
|
+
for (const line of sidebar) assert.equal(visibleWidth(line), 32);
|
|
112
|
+
const unknown = renderAgentsCard([task({ model: "default", thinking: undefined })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
|
|
113
|
+
assert.doesNotMatch(unknown, /default|undefined|high|off/);
|
|
114
|
+
const off = renderAgentsCard([task({ thinking: "off" })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
|
|
115
|
+
assert.match(off, /claude-sonnet-5 · off/);
|
|
116
|
+
});
|
|
117
|
+
|
|
88
118
|
test("widgetRows caps the card at a quarter of the terminal, between three and eight rows", () => {
|
|
89
119
|
assert.equal(widgetRows(40), 8, "tall terminals still stop at eight rows");
|
|
90
120
|
assert.equal(widgetRows(24), 6);
|
|
@@ -149,6 +149,27 @@ test("SDD proposal questions focus on business and PRD gaps", async () => {
|
|
|
149
149
|
);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test("all shipped SDD agents and chains require parent preflight transport", async () => {
|
|
153
|
+
const agents = [
|
|
154
|
+
"sdd-init", "sdd-onboard", "sdd-explore", "sdd-research", "sdd-proposal", "sdd-spec", "sdd-design",
|
|
155
|
+
"sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-sync", "sdd-archive", "sdd-remediate",
|
|
156
|
+
];
|
|
157
|
+
const chains = ["sdd-full", "sdd-plan", "sdd-verify"];
|
|
158
|
+
assert.equal(agents.length, 14);
|
|
159
|
+
assert.equal(chains.length, 3);
|
|
160
|
+
for (const agent of agents) {
|
|
161
|
+
const source = await readFile(join(ROOT, "assets", "agents", `${agent}.md`), "utf8");
|
|
162
|
+
assert.match(source, /## Parent Preflight Transport/);
|
|
163
|
+
assert.match(source, /never confirms or persists SDD choices/);
|
|
164
|
+
assert.doesNotMatch(source, /(?:infer|synthesize) (?:a )?(?:preflight )?(?:confirmation|defaults?) yourself/i);
|
|
165
|
+
}
|
|
166
|
+
for (const chain of chains) {
|
|
167
|
+
const source = await readFile(join(ROOT, "assets", "chains", `${chain}.chain.md`), "utf8");
|
|
168
|
+
assert.match(source, /## Parent preflight transport guard/);
|
|
169
|
+
assert.match(source, /Missing or malformed transport blocks the chain before its first phase/);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
152
173
|
test("SDD chain assets distinguish interactive gates from auto execution", async () => {
|
|
153
174
|
const planChain = await readFile(join(ROOT, "assets/chains/sdd-plan.chain.md"), "utf8");
|
|
154
175
|
const fullChain = await readFile(join(ROOT, "assets/chains/sdd-full.chain.md"), "utf8");
|
|
@@ -212,8 +233,10 @@ test("SDD assets route completed implementation directly through verify, sync, a
|
|
|
212
233
|
|
|
213
234
|
assert.match(tasks, /<!-- sdd-owner: implementation -->/);
|
|
214
235
|
assert.match(apply, /next_recommended: "sdd-verify"/);
|
|
215
|
-
assert.match(status, /
|
|
216
|
-
assert.match(
|
|
236
|
+
assert.match(status, /gentle-ai\.sdd-status.*v2/i);
|
|
237
|
+
assert.match(status, /read-only/i);
|
|
238
|
+
assert.match(contract, /native.*(?:apply|verify).*archive/is);
|
|
239
|
+
assert.match(contract, /Manual `sdd-sync` remains its intentional local resolver/);
|
|
217
240
|
assert.match(chain, /apply.*verification/is);
|
|
218
241
|
assert.doesNotMatch(assets, /<!-- sdd-owner: parent -->/);
|
|
219
242
|
assert.doesNotMatch(assets, /parent-lifecycle/);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getKeybindings, KeybindingsManager, setKeybindings, stripTerminalSequences, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
|
|
4
4
|
import askUserChoice from "../extensions/ask-user-choice.ts";
|
|
5
5
|
|
|
6
6
|
interface ChoiceResult {
|
|
@@ -17,6 +17,7 @@ interface ChoiceParameters {
|
|
|
17
17
|
additionalProperties?: boolean;
|
|
18
18
|
properties?: {
|
|
19
19
|
question?: unknown;
|
|
20
|
+
allowCustomResponse?: unknown;
|
|
20
21
|
options?: {
|
|
21
22
|
minItems?: number;
|
|
22
23
|
maxItems?: number;
|
|
@@ -26,7 +27,10 @@ interface ChoiceParameters {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface ChoiceTool {
|
|
30
|
+
renderShell?: string;
|
|
29
31
|
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
promptGuidelines?: string[];
|
|
30
34
|
parameters: ChoiceParameters;
|
|
31
35
|
execute: (...args: unknown[]) => Promise<ChoiceResult>;
|
|
32
36
|
}
|
|
@@ -151,6 +155,10 @@ function tuiContext(inputs: readonly string[], rendered: { value: string }) {
|
|
|
151
155
|
};
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
test("ask_user_choice transcript opts out of Pi's painted tool shell", () => {
|
|
159
|
+
assert.equal(registerChoiceTool().tool.renderShell, "self");
|
|
160
|
+
});
|
|
161
|
+
|
|
154
162
|
test("ask_user_choice registers without runtime actions or overriding the open questionnaire", () => {
|
|
155
163
|
const registration = registerChoiceTool(["read", "ask_user_question"]);
|
|
156
164
|
|
|
@@ -159,14 +167,17 @@ test("ask_user_choice registers without runtime actions or overriding the open q
|
|
|
159
167
|
assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
|
|
160
168
|
});
|
|
161
169
|
|
|
162
|
-
test("ask_user_choice
|
|
170
|
+
test("ask_user_choice keeps closed mode by default and makes custom mode opt-in", () => {
|
|
163
171
|
const { tool } = registerChoiceTool();
|
|
164
172
|
const optionsSchema = tool.parameters.properties?.options;
|
|
165
173
|
const optionSchema = optionsSchema?.items;
|
|
166
174
|
|
|
167
175
|
assert.equal(tool.name, "ask_user_choice");
|
|
168
176
|
assert.equal(tool.parameters.additionalProperties, false);
|
|
169
|
-
assert.deepEqual(Object.keys(tool.parameters.properties ?? {}).sort(), ["options", "question"]);
|
|
177
|
+
assert.deepEqual(Object.keys(tool.parameters.properties ?? {}).sort(), ["allowCustomResponse", "options", "question"]);
|
|
178
|
+
assert.equal(tool.parameters.properties?.allowCustomResponse !== undefined, true);
|
|
179
|
+
assert.match(tool.description ?? "", /allowCustomResponse/);
|
|
180
|
+
assert.match((tool.promptGuidelines ?? []).join(" "), /Never enable allowCustomResponse.*provider-owned consent prompts.*maintenance authorizations.*exact opaque token/);
|
|
170
181
|
assert.equal(optionsSchema?.minItems, 2);
|
|
171
182
|
assert.equal(optionsSchema?.maxItems, 4);
|
|
172
183
|
assert.equal(optionSchema?.additionalProperties, false);
|
|
@@ -295,6 +306,161 @@ test("ask_user_choice retains native rendered hit testing for mouse selection",
|
|
|
295
306
|
assert.equal(completionCalls, 1, "late keyboard input cannot complete the choice twice");
|
|
296
307
|
});
|
|
297
308
|
|
|
309
|
+
test("ask_user_choice supports an opt-in custom response from keyboard navigation", async () => {
|
|
310
|
+
const { tool } = registerChoiceTool();
|
|
311
|
+
const rendered: string[] = [];
|
|
312
|
+
const response = " exactly as typed ";
|
|
313
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
|
|
314
|
+
mode: "tui",
|
|
315
|
+
ui: {
|
|
316
|
+
custom: async (factory: ChoiceCustomFactory) => {
|
|
317
|
+
let completed: unknown;
|
|
318
|
+
const component = factory(
|
|
319
|
+
{ requestRender() {} },
|
|
320
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
321
|
+
{},
|
|
322
|
+
(value) => { completed = value; },
|
|
323
|
+
);
|
|
324
|
+
rendered.push(component.render(80).join("\n"));
|
|
325
|
+
component.handleInput("\x1b[B");
|
|
326
|
+
component.handleInput("\x1b[B");
|
|
327
|
+
component.handleInput("\r");
|
|
328
|
+
rendered.push(component.render(80).join("\n"));
|
|
329
|
+
component.handleInput(response);
|
|
330
|
+
component.handleInput("\r");
|
|
331
|
+
return completed;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
assert.match(rendered[0] ?? "", /Other…/);
|
|
336
|
+
assert.match(rendered[1] ?? "", /Custom response/);
|
|
337
|
+
assert.deepEqual(result.details, { question: "Explain?", options, customResponse: response });
|
|
338
|
+
assert.equal(result.content[0]?.text, `User responded: ${response}`);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("ask_user_choice uses native input decoding and supplied custom editor keybindings", async () => {
|
|
342
|
+
const { tool } = registerChoiceTool();
|
|
343
|
+
const configured = new KeybindingsManager(TUI_KEYBINDINGS, {
|
|
344
|
+
"tui.select.down": "ctrl+n",
|
|
345
|
+
"tui.select.confirm": "ctrl+o",
|
|
346
|
+
"tui.select.cancel": "ctrl+x",
|
|
347
|
+
"tui.input.submit": "ctrl+s",
|
|
348
|
+
"tui.editor.deleteCharBackward": "ctrl+h",
|
|
349
|
+
});
|
|
350
|
+
const previous = getKeybindings();
|
|
351
|
+
setKeybindings(configured);
|
|
352
|
+
try {
|
|
353
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
|
|
354
|
+
mode: "tui",
|
|
355
|
+
ui: { custom: async (factory: ChoiceCustomFactory) => {
|
|
356
|
+
let completed: unknown;
|
|
357
|
+
const component = factory(
|
|
358
|
+
{ requestRender() {} },
|
|
359
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
360
|
+
configured, (value) => { completed = value; },
|
|
361
|
+
);
|
|
362
|
+
component.handleInput("\x0e");
|
|
363
|
+
component.handleInput("\x0e");
|
|
364
|
+
component.handleInput("\x0f");
|
|
365
|
+
component.handleInput("\x1b[97u");
|
|
366
|
+
component.handleInput("\x1b[98u");
|
|
367
|
+
component.handleInput("\x08");
|
|
368
|
+
component.handleInput("\x18");
|
|
369
|
+
assert.match(component.render(80).join("\n"), /Other…/);
|
|
370
|
+
component.handleInput("\x0f");
|
|
371
|
+
component.handleInput("\x13");
|
|
372
|
+
return completed;
|
|
373
|
+
} },
|
|
374
|
+
});
|
|
375
|
+
assert.deepEqual(result.details, { question: "Explain?", options, customResponse: "a" });
|
|
376
|
+
} finally {
|
|
377
|
+
setKeybindings(previous);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("ask_user_choice activates an opt-in custom response from the Other row pointer target", async () => {
|
|
382
|
+
const { tool } = registerChoiceTool();
|
|
383
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
|
|
384
|
+
mode: "tui",
|
|
385
|
+
ui: {
|
|
386
|
+
custom: async (factory: ChoiceCustomFactory) => {
|
|
387
|
+
let completed: unknown;
|
|
388
|
+
const component = factory(
|
|
389
|
+
{ requestRender() {} },
|
|
390
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
391
|
+
{},
|
|
392
|
+
(value) => { completed = value; },
|
|
393
|
+
);
|
|
394
|
+
const lines = component.render(80);
|
|
395
|
+
const otherRow = lines.findIndex((line) => stripTerminalSequences(line).includes("Other…"));
|
|
396
|
+
assert.ok(otherRow >= 0);
|
|
397
|
+
const event = (type: string) => ({
|
|
398
|
+
type, button: "left", x: 0, y: otherRow, screenX: 0, screenY: otherRow,
|
|
399
|
+
width: 80, height: lines.length, shift: false, alt: false, ctrl: false,
|
|
400
|
+
});
|
|
401
|
+
assert.equal(component.handleMouse?.(event("press"))?.focus, true);
|
|
402
|
+
component.handleMouse?.(event("click"));
|
|
403
|
+
component.handleInput("pointer response");
|
|
404
|
+
component.handleInput("\r");
|
|
405
|
+
return completed;
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
assert.deepEqual(result.details, { question: "Explain?", options, customResponse: "pointer response" });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("ask_user_choice rejects empty custom responses and escapes back to list cancellation", async () => {
|
|
413
|
+
const { tool } = registerChoiceTool();
|
|
414
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, {
|
|
415
|
+
mode: "tui",
|
|
416
|
+
ui: {
|
|
417
|
+
custom: async (factory: ChoiceCustomFactory) => {
|
|
418
|
+
let completed: unknown;
|
|
419
|
+
const choice = factory(
|
|
420
|
+
{ requestRender() {} },
|
|
421
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
422
|
+
{},
|
|
423
|
+
(value) => { completed = value; },
|
|
424
|
+
);
|
|
425
|
+
choice.handleInput("\x1b[B");
|
|
426
|
+
choice.handleInput("\x1b[B");
|
|
427
|
+
choice.handleInput("\r");
|
|
428
|
+
assert.match(choice.render(80).join("\n"), /Custom response/);
|
|
429
|
+
choice.handleInput(" ");
|
|
430
|
+
choice.handleInput("\r");
|
|
431
|
+
assert.equal(completed, undefined, "whitespace-only submission stays in the editor");
|
|
432
|
+
assert.match(choice.render(80).join("\n"), /Custom response/);
|
|
433
|
+
choice.handleInput("\x1b");
|
|
434
|
+
assert.match(choice.render(80).join("\n"), /Other…/);
|
|
435
|
+
choice.handleInput("\x1b");
|
|
436
|
+
return completed;
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
assert.deepEqual(result.details, { question: "Explain?", options, cancelled: true });
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("ask_user_choice renders Other only when custom response is explicitly enabled", async () => {
|
|
444
|
+
const { tool } = registerChoiceTool();
|
|
445
|
+
for (const allowCustomResponse of [undefined, false, true]) {
|
|
446
|
+
let rendered = "";
|
|
447
|
+
await tool.execute("call", { question: "Proceed?", options, ...(allowCustomResponse === undefined ? {} : { allowCustomResponse }) }, new AbortController().signal, undefined, {
|
|
448
|
+
mode: "tui",
|
|
449
|
+
ui: { custom: async (factory: ChoiceCustomFactory) => {
|
|
450
|
+
const component = factory(
|
|
451
|
+
{ requestRender() {} },
|
|
452
|
+
{ fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text },
|
|
453
|
+
{}, () => {},
|
|
454
|
+
);
|
|
455
|
+
rendered = component.render(80).join("\n");
|
|
456
|
+
component.handleInput("\x1b");
|
|
457
|
+
return undefined;
|
|
458
|
+
} },
|
|
459
|
+
});
|
|
460
|
+
assert.equal(rendered.includes("Other…"), allowCustomResponse === true);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
298
464
|
test("ask_user_choice handles a closed Kilo hash decision with an opaque envelope value", async () => {
|
|
299
465
|
const { tool } = registerChoiceTool(["read"]);
|
|
300
466
|
const rendered = { value: "" };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
async function proveLazyDiscovery(): Promise<void> {
|
|
10
|
+
const {
|
|
11
|
+
createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices,
|
|
12
|
+
ModelRuntime, SessionManager, SettingsManager,
|
|
13
|
+
} = await import("@earendil-works/pi-coding-agent");
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR!;
|
|
16
|
+
const shim = join(cwd, "extensions.ts");
|
|
17
|
+
const source = (name: string) => JSON.stringify(new URL(`../extensions/${name}.ts`, import.meta.url).href);
|
|
18
|
+
writeFileSync(shim, `
|
|
19
|
+
import { createGentleAiExtension } from ${source("gentle-ai")};
|
|
20
|
+
import gentleAgents from ${source("gentle-agents")};
|
|
21
|
+
import sddInit from ${source("sdd-init")};
|
|
22
|
+
export default function (pi) {
|
|
23
|
+
createGentleAiExtension({ nativeReviewCli: null, candidateViews: null, processEnv: {} })(pi);
|
|
24
|
+
gentleAgents(pi);
|
|
25
|
+
sddInit(pi);
|
|
26
|
+
}
|
|
27
|
+
`);
|
|
28
|
+
const modelRuntime = await ModelRuntime.create({
|
|
29
|
+
authPath: join(agentDir, "empty-auth.json"),
|
|
30
|
+
modelsPath: null,
|
|
31
|
+
modelsStorePath: join(agentDir, "models-store.json"),
|
|
32
|
+
allowModelNetwork: false,
|
|
33
|
+
});
|
|
34
|
+
const runtime = await createAgentSessionRuntime(async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
35
|
+
const services = await createAgentSessionServices({
|
|
36
|
+
cwd, agentDir, modelRuntime,
|
|
37
|
+
settingsManager: SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }),
|
|
38
|
+
resourceLoaderOptions: {
|
|
39
|
+
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
|
|
40
|
+
additionalExtensionPaths: [shim],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
assert.deepEqual(services.resourceLoader.getExtensions().errors, []);
|
|
44
|
+
assert.deepEqual(services.diagnostics, []);
|
|
45
|
+
return {
|
|
46
|
+
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, tools: ["subagent_list_agents"] })),
|
|
47
|
+
services, diagnostics: services.diagnostics,
|
|
48
|
+
};
|
|
49
|
+
}, { cwd, agentDir, sessionManager: SessionManager.inMemory(cwd) });
|
|
50
|
+
try {
|
|
51
|
+
await runtime.session.bindExtensions({ mode: "print" });
|
|
52
|
+
const session = runtime.session;
|
|
53
|
+
const list = session.agent.state.tools.find(tool => tool.name === "subagent_list_agents");
|
|
54
|
+
assert.ok(list, "actual SDK must expose the wrapped discovery tool");
|
|
55
|
+
const names = async () => {
|
|
56
|
+
const result = await list.execute("asset-discovery", {});
|
|
57
|
+
return result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
|
|
58
|
+
};
|
|
59
|
+
const before = await names();
|
|
60
|
+
assert.match(before, /- gentle-ai-explore \(global\)/);
|
|
61
|
+
assert.match(before, /- review-risk \(global\)/);
|
|
62
|
+
assert.doesNotMatch(before, /- sdd-/, "fresh startup must not install SDD definitions");
|
|
63
|
+
assert.equal(existsSync(join(agentDir, "chains", "sdd-full.chain.md")), false);
|
|
64
|
+
assert.equal(existsSync(join(agentDir, "gentle-ai", "support")), false);
|
|
65
|
+
await session.prompt("/gentle:install-sdd");
|
|
66
|
+
assert.strictEqual(runtime.session, session);
|
|
67
|
+
const after = await names();
|
|
68
|
+
for (const name of ["gentle-ai-explore", "review-risk", "sdd-init", "sdd-apply"]) {
|
|
69
|
+
assert.ok(after.includes(`- ${name} (global)`), `same-session discovery must include ${name}`);
|
|
70
|
+
}
|
|
71
|
+
for (const file of ["sdd-status-contract.md", "strict-tdd.md", "strict-tdd-verify.md"]) {
|
|
72
|
+
assert.ok(existsSync(join(agentDir, "gentle-ai", "support", file)), `missing support: ${file}`);
|
|
73
|
+
}
|
|
74
|
+
assert.ok(existsSync(join(agentDir, "chains", "sdd-full.chain.md")));
|
|
75
|
+
assert.equal(session.messages.length, 0, "slash activation must not start a model turn");
|
|
76
|
+
console.log("SDK discovery: delegation/review only -> /gentle:install-sdd -> same-session sdd-init/sdd-apply and support");
|
|
77
|
+
} finally {
|
|
78
|
+
await runtime.dispose();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (process.env.GENTLE_PI_ASSET_PROOF_CHILD === "1") {
|
|
83
|
+
await proveLazyDiscovery();
|
|
84
|
+
} else {
|
|
85
|
+
test("actual SDK discovers SDD only after explicit activation in the same session", () => {
|
|
86
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-assets-sdk-"));
|
|
87
|
+
try {
|
|
88
|
+
const home = join(root, "home");
|
|
89
|
+
const cwd = join(root, "project");
|
|
90
|
+
const agentDir = join(home, ".pi", "agent");
|
|
91
|
+
for (const path of [cwd, agentDir]) mkdirSync(path, { recursive: true });
|
|
92
|
+
const result = spawnSync(process.execPath, ["--experimental-strip-types", fileURLToPath(import.meta.url)], {
|
|
93
|
+
cwd, encoding: "utf8", timeout: 30_000,
|
|
94
|
+
env: {
|
|
95
|
+
PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, HOME: home, TMPDIR: root,
|
|
96
|
+
PI_CODING_AGENT_DIR: agentDir, GENTLE_PI_AGENT_HOME: agentDir,
|
|
97
|
+
GENTLE_PI_CONFIG_HOME: join(home, "config"), XDG_CONFIG_HOME: join(home, ".config"),
|
|
98
|
+
PI_OFFLINE: "1", GENTLE_PI_ASSET_PROOF_CHILD: "1",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
assert.ifError(result.error);
|
|
102
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
103
|
+
assert.match(result.stdout, /SDK discovery: delegation\/review only/);
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(root, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
7
7
|
|
|
8
|
-
const { classifyGuardedCommand } = __testing;
|
|
8
|
+
const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle } = __testing;
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
// Helpers
|
|
@@ -129,6 +129,121 @@ test("classifyGuardedCommand: git push plain allowed when autonomousMode=true an
|
|
|
129
129
|
assert.equal(result, "allow");
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
test("classifyGuardedCommand: configured push allow is remote-agnostic and unchanged", () => {
|
|
133
|
+
const config = { autonomousMode: true, guardedCommands: { gitPush: "allow" as const } };
|
|
134
|
+
for (const command of [
|
|
135
|
+
"git push origin feature/test",
|
|
136
|
+
"git push upstream feature/test",
|
|
137
|
+
"git push fork feature/test",
|
|
138
|
+
"git push",
|
|
139
|
+
"git push feature/test && git status",
|
|
140
|
+
"echo done || git push origin feature/test",
|
|
141
|
+
]) {
|
|
142
|
+
assert.equal(classifyGuardedCommand(command, config), "allow", command);
|
|
143
|
+
}
|
|
144
|
+
assert.equal(
|
|
145
|
+
classifyGuardedCommand("git push origin feature/test", { autonomousMode: true, guardedCommands: { gitPush: "block" } }),
|
|
146
|
+
"block",
|
|
147
|
+
);
|
|
148
|
+
for (const command of [
|
|
149
|
+
"git push -f origin feature/test",
|
|
150
|
+
"git push --force origin main",
|
|
151
|
+
"git push --force-with-lease origin feature/test",
|
|
152
|
+
]) {
|
|
153
|
+
assert.equal(classifyGuardedCommand(command, config), "block", command);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Guarded command preview — presentation-only centering
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
test("guardedCommandPreview: long late matched action stays visible in preview", () => {
|
|
162
|
+
const prefix = "noise ".repeat(80);
|
|
163
|
+
const command = `${prefix}git push origin feature/test`;
|
|
164
|
+
// Contract: the caller owns the trigger index. confirmCommand hands over
|
|
165
|
+
// evaluation.triggerIndex, so the preview always centers on the matched
|
|
166
|
+
// action supplied by the evaluator.
|
|
167
|
+
const triggerIndex = command.indexOf("git push");
|
|
168
|
+
const preview = guardedCommandPreview(command, triggerIndex);
|
|
169
|
+
assert.match(preview, /git push origin feature\/test/);
|
|
170
|
+
assert.ok(preview.startsWith("…"), "preview elides leading context near a late match");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("guardedCommandPreview: late non-push action visible, elided, and width-bounded", () => {
|
|
174
|
+
const prefix = "noise ".repeat(80);
|
|
175
|
+
const command = `${prefix}npm publish --tag beta`;
|
|
176
|
+
const triggerIndex = command.indexOf("npm publish");
|
|
177
|
+
const preview = guardedCommandPreview(command, triggerIndex);
|
|
178
|
+
assert.match(preview, /npm publish --tag beta/);
|
|
179
|
+
assert.ok(preview.startsWith("…"), "preview elides leading context near a late match");
|
|
180
|
+
// Bounded visible width: 1 leading ellipsis + up to 179 content chars (budget 180).
|
|
181
|
+
assert.ok(preview.length <= 180, `preview exceeds bounded width: ${preview.length}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Guarded command title — presentation-only, action-specific
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
test("guardedCommandTitle: exact action-specific titles per guarded key", () => {
|
|
189
|
+
assert.equal(guardedCommandTitle("gitPush"), "Allow guarded git push?");
|
|
190
|
+
assert.equal(guardedCommandTitle("gitRebase"), "Allow guarded git rebase?");
|
|
191
|
+
assert.equal(
|
|
192
|
+
guardedCommandTitle("gitBranchDeleteForce"),
|
|
193
|
+
"Allow guarded forced git branch deletion?",
|
|
194
|
+
);
|
|
195
|
+
assert.equal(guardedCommandTitle("npmPublish"), "Allow guarded npm publish?");
|
|
196
|
+
assert.equal(guardedCommandTitle("piRemove"), "Allow guarded pi remove?");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("guardedCommandTitle: unkeyed guarded command falls back to generic title", () => {
|
|
200
|
+
assert.equal(guardedCommandTitle(undefined), "Allow guarded command?");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("evaluateGuardedCommand: collects guarded matches in command order and applies full-command precedence", () => {
|
|
204
|
+
const command = "npm publish && git push origin main && git rebase main";
|
|
205
|
+
const evaluation = evaluateGuardedCommand(command, {
|
|
206
|
+
autonomousMode: true,
|
|
207
|
+
guardedCommands: { gitPush: "allow", gitRebase: "confirm", npmPublish: "block" },
|
|
208
|
+
});
|
|
209
|
+
assert.deepEqual(evaluation.matches.map((match) => [match.key, match.action]), [
|
|
210
|
+
["npmPublish", "block"],
|
|
211
|
+
["gitPush", "allow"],
|
|
212
|
+
["gitRebase", "confirm"],
|
|
213
|
+
]);
|
|
214
|
+
assert.deepEqual(evaluation.matches.map((match) => command.slice(match.triggerIndex).split(/\s/, 1)[0]), ["publish", "push", "rebase"]);
|
|
215
|
+
assert.equal(evaluation.action, "block");
|
|
216
|
+
|
|
217
|
+
assert.equal(evaluateGuardedCommand("git rebase main && git push --force origin main", {
|
|
218
|
+
autonomousMode: true,
|
|
219
|
+
guardedCommands: { gitRebase: "allow", gitPush: "allow" },
|
|
220
|
+
}).action, "block", "a later hard deny overrides an earlier allowed action");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("evaluateGuardedCommand: git -C stores the push token offset for bounded previews", () => {
|
|
224
|
+
const command = `git -C /${"very-long-path/".repeat(30)} push origin main`;
|
|
225
|
+
const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
|
|
226
|
+
assert.equal(command.slice(evaluation.triggerIndex, evaluation.triggerIndex + 4), "push");
|
|
227
|
+
assert.match(guardedCommandPreview(command, evaluation.triggerIndex), /push origin main/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("evaluateGuardedCommand: git -C path containing push still offsets the push action", () => {
|
|
231
|
+
const command = `git -C /${"push-path/".repeat(30)} push origin main`;
|
|
232
|
+
const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
|
|
233
|
+
assert.equal(evaluation.triggerIndex, command.lastIndexOf("push"));
|
|
234
|
+
assert.match(guardedCommandPreview(command, evaluation.triggerIndex), /push origin main/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("evaluateGuardedCommand: repeated forced branch deletions remain separate matches", () => {
|
|
238
|
+
const command = "git branch --delete --force old && git branch --delete --force older";
|
|
239
|
+
const evaluation = evaluateGuardedCommand(command, { autonomousMode: false, guardedCommands: {} });
|
|
240
|
+
assert.deepEqual(evaluation.matches.map((match) => match.key), ["gitBranchDeleteForce", "gitBranchDeleteForce"]);
|
|
241
|
+
assert.equal(
|
|
242
|
+
guardedCommandTitle(evaluation.key, evaluation.matches),
|
|
243
|
+
"Allow guarded actions: forced git branch deletion; forced git branch deletion?",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
132
247
|
test("classifyGuardedCommand: git push plain still confirm when autonomousMode=false even with gitPush=allow in config", () => {
|
|
133
248
|
const result = classifyGuardedCommand("git push origin feature/test", {
|
|
134
249
|
autonomousMode: false,
|
|
@@ -191,7 +191,7 @@ test("CodeGraph tool rejects incomplete or oversized query requests before runni
|
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
test("CodeGraph tool registration exposes a single constrained custom tool", () => {
|
|
194
|
-
const tools: Array<{ name: string; parameters: Record<string, unknown> }> = [];
|
|
194
|
+
const tools: Array<{ name: string; renderShell?: string; parameters: Record<string, unknown> }> = [];
|
|
195
195
|
const pi = {
|
|
196
196
|
registerTool(tool: { name: string; parameters: Record<string, unknown> }) {
|
|
197
197
|
tools.push(tool);
|
|
@@ -202,6 +202,7 @@ test("CodeGraph tool registration exposes a single constrained custom tool", ()
|
|
|
202
202
|
|
|
203
203
|
assert.equal(tools.length, 1);
|
|
204
204
|
assert.equal(tools[0]?.name, "codegraph");
|
|
205
|
+
assert.equal(tools[0]?.renderShell, "self", "CodeGraph must not inherit Pi's painted Box");
|
|
205
206
|
assert.deepEqual(tools[0]?.parameters, {
|
|
206
207
|
type: "object",
|
|
207
208
|
additionalProperties: false,
|
|
@@ -92,7 +92,7 @@ test("SDD executor coverage is exhaustive against actual agent files", () => {
|
|
|
92
92
|
// the allowlist matches reality so coverage cannot silently drift.
|
|
93
93
|
const expected = [
|
|
94
94
|
"sdd-apply.md", "sdd-archive.md", "sdd-design.md", "sdd-explore.md",
|
|
95
|
-
"sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-research.md",
|
|
95
|
+
"sdd-init.md", "sdd-onboard.md", "sdd-proposal.md", "sdd-remediate.md", "sdd-research.md",
|
|
96
96
|
"sdd-spec.md", "sdd-status.md", "sdd-sync.md", "sdd-tasks.md",
|
|
97
97
|
"sdd-verify.md",
|
|
98
98
|
];
|
|
@@ -306,6 +306,8 @@ test("dev-binary: v7 intended-untracked selection decodes both selected statuses
|
|
|
306
306
|
assert.equal("lineage_created" in result, false, "reconciled unknown mutation must not claim lineage creation");
|
|
307
307
|
assert.equal("mutation_performed" in result, false, "reconciled unknown mutation must not claim a mutation result");
|
|
308
308
|
assert.equal(result.mutation_outcome, "unknown", "the controller cannot prove mutation status after an intercepted launch");
|
|
309
|
+
assert.match(String(result.required_status_action), /Run target-scoped review\.status/, "ambiguous START must retain its executable-safe STATUS instruction");
|
|
310
|
+
assert.equal(result.next_action, "start", "the reconciled provider action remains visible without authoring a replay");
|
|
309
311
|
const after = await native.targetStatus({ cwd, agent: "pi" });
|
|
310
312
|
assert.equal(after.authority, undefined, "post-intercept STATUS must not expose authority");
|
|
311
313
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
// Reuse Pi's existing YAML dependency without adding a package dependency.
|
|
7
|
+
const piRequire = createRequire(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
8
|
+
const { parseDocument } = piRequire("yaml");
|
|
9
|
+
const formUrl = new URL("../.github/ISSUE_TEMPLATE/feature_request.yml", import.meta.url);
|
|
10
|
+
|
|
11
|
+
function readForm() {
|
|
12
|
+
const document = parseDocument(readFileSync(formUrl, "utf8"), { uniqueKeys: true });
|
|
13
|
+
assert.deepEqual(document.errors, [], "the issue form must be valid, unambiguous YAML");
|
|
14
|
+
return document.toJS();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("feature request form has supported structure and only intake labels", () => {
|
|
18
|
+
const form = readForm();
|
|
19
|
+
assert.deepEqual(Object.keys(form).sort(), ["body", "description", "labels", "name"]);
|
|
20
|
+
assert.equal(form.name, "Feature Request");
|
|
21
|
+
assert.equal(typeof form.description, "string");
|
|
22
|
+
assert.ok(form.description.trim().length > 0);
|
|
23
|
+
// Exact allowlist also rejects approval, exception, and other privileged labels.
|
|
24
|
+
assert.deepEqual(form.labels, ["enhancement", "status:needs-review"]);
|
|
25
|
+
assert.ok(Array.isArray(form.body));
|
|
26
|
+
assert.deepEqual(form.body.map((field) => field.id), [
|
|
27
|
+
"preflight", "problem", "outcome", "alternatives", "context",
|
|
28
|
+
]);
|
|
29
|
+
for (const field of form.body) {
|
|
30
|
+
assert.ok(["checkboxes", "textarea"].includes(field.type));
|
|
31
|
+
assert.match(field.id, /^[a-zA-Z0-9_-]+$/);
|
|
32
|
+
assert.equal(typeof field.attributes.label, "string");
|
|
33
|
+
assert.ok(field.attributes.label.trim().length > 0);
|
|
34
|
+
assert.deepEqual(Object.keys(field).sort(), field.type === "checkboxes"
|
|
35
|
+
? ["attributes", "id", "type"]
|
|
36
|
+
: ["attributes", "id", "type", "validations"]);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("duplicate and privacy affirmations are individually required", () => {
|
|
41
|
+
const [preflight] = readForm().body;
|
|
42
|
+
assert.equal(preflight.type, "checkboxes");
|
|
43
|
+
assert.deepEqual(preflight.attributes.options, [
|
|
44
|
+
{
|
|
45
|
+
label: "I searched open and closed issues and did not find a request for this feature.",
|
|
46
|
+
required: true,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: "I reviewed this request and removed credentials, tokens, private paths, hostnames, and other sensitive data.",
|
|
50
|
+
required: true,
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("feature questions require problem and outcome but keep alternatives and context optional", () => {
|
|
56
|
+
const questions = readForm().body.slice(1);
|
|
57
|
+
assert.deepEqual(questions.map((field) => ({
|
|
58
|
+
type: field.type,
|
|
59
|
+
label: field.attributes.label,
|
|
60
|
+
validations: field.validations,
|
|
61
|
+
})), [
|
|
62
|
+
{ type: "textarea", label: "Problem or opportunity", validations: { required: true } },
|
|
63
|
+
{ type: "textarea", label: "Proposed outcome", validations: { required: true } },
|
|
64
|
+
{ type: "textarea", label: "Alternatives considered", validations: { required: false } },
|
|
65
|
+
{ type: "textarea", label: "Additional context", validations: { required: false } },
|
|
66
|
+
]);
|
|
67
|
+
});
|