gentle-pi 4.2.0 → 4.3.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 +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -88,6 +88,7 @@ interface FakeUi {
|
|
|
88
88
|
overlay: unknown;
|
|
89
89
|
overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
|
|
90
90
|
closeOverlay: (() => void) | undefined;
|
|
91
|
+
customOptions?: unknown[];
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
interface GitScript {
|
|
@@ -175,7 +176,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
178
|
-
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
179
|
+
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined, customOptions: [] };
|
|
179
180
|
let resolveOverlay: () => void;
|
|
180
181
|
const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
|
|
181
182
|
const entries = options.entries ?? [];
|
|
@@ -226,8 +227,9 @@ function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): stri
|
|
|
226
227
|
// whatever their `done` callback is given (see ExtensionUIContext.custom
|
|
227
228
|
// in pi-coding-agent), not always null. `closeOverlay` stays a
|
|
228
229
|
// null-resolving escape hatch for tests that only need to end the wait.
|
|
229
|
-
custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
|
|
230
|
+
custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }, options?: unknown) {
|
|
230
231
|
ui.overlay = factory;
|
|
232
|
+
ui.customOptions?.push(options);
|
|
231
233
|
return new Promise<T | null>((resolve) => {
|
|
232
234
|
ui.closeOverlay = () => resolve(null);
|
|
233
235
|
// An identity bg mock hides focused rows; omit it so overlay navigation
|
|
@@ -4775,6 +4777,35 @@ test("a registered worktree opens Changes as its own group", async () => {
|
|
|
4775
4777
|
} finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
|
|
4776
4778
|
});
|
|
4777
4779
|
|
|
4780
|
+
// /gentle:changes opens like Gentle Agents: a full-terminal overlay whose
|
|
4781
|
+
// native fullscreen wrapper still routes mouse events to the changes view.
|
|
4782
|
+
test("Changes opens full screen and the wrapper keeps mouse hit-testing aligned", async () => {
|
|
4783
|
+
const {pi,handlers,commands}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
|
|
4784
|
+
gentleShell(pi,{});
|
|
4785
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
4786
|
+
await fire(handlers,"session_start",ctx);
|
|
4787
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4788
|
+
await overlayReady;
|
|
4789
|
+
const overlay=ui.overlayView as unknown as { render(width: number): string[]; handleInput(data: string): void; handleMouse(event: unknown): unknown };
|
|
4790
|
+
const at=(type: string, x: number, y: number) => ({ type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width: 140, height: 40, shift: false, alt: false, ctrl: false });
|
|
4791
|
+
try {
|
|
4792
|
+
assert.deepEqual(ui.customOptions?.at(-1), { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
|
|
4793
|
+
assert.equal(overlay.render(140).length, 40, "the overlay uses every terminal row");
|
|
4794
|
+
overlay.handleInput("\r");
|
|
4795
|
+
overlay.render(140);
|
|
4796
|
+
const { handled, render } = overlay.handleMouse(at("click", 3, 3)) as { handled?: boolean; render?: boolean };
|
|
4797
|
+
assert.deepEqual({ handled, render }, { handled: true, render: true });
|
|
4798
|
+
assert.match(overlay.render(140)[3]!, /▸ M file\.ts/, "a click through the wrapper selects the file");
|
|
4799
|
+
overlay.handleInput("F");
|
|
4800
|
+
assert.match(overlay.render(140)[1]!, /^│ M file\.ts +│$/, "F switches to diff-only inside the fullscreen overlay");
|
|
4801
|
+
} finally { ui.closeOverlay?.(); await opened; }
|
|
4802
|
+
const reopened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4803
|
+
for (let attempt=0; attempt<100 && ui.overlayView===overlay; attempt++) await new Promise((resolve)=>setTimeout(resolve,5));
|
|
4804
|
+
try {
|
|
4805
|
+
assert.match(ui.overlayView!.render(140)[1]!, /main · repo/, "diff-only mode is not persisted across openings");
|
|
4806
|
+
} finally { ui.closeOverlay?.(); await reopened; await fire(handlers,"session_shutdown",ctx); }
|
|
4807
|
+
});
|
|
4808
|
+
|
|
4778
4809
|
test("new sessions do not inherit another session's worktrees", async () => {
|
|
4779
4810
|
const h=fakePi(); gentleShell(h.pi,{});
|
|
4780
4811
|
const first=fakeContext();
|
|
@@ -4849,7 +4880,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
4849
4880
|
assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
|
|
4850
4881
|
const opened = h.commands.get("gentle:changes")!.handler("", ctx);
|
|
4851
4882
|
await overlayReady;
|
|
4852
|
-
const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
|
|
4883
|
+
const trees = (ui.overlayView as unknown as { children: Array<{ trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }> }).children[0]!.trees;
|
|
4853
4884
|
assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
|
|
4854
4885
|
ui.closeOverlay?.();
|
|
4855
4886
|
await opened;
|
|
@@ -6281,19 +6312,21 @@ test("an explicit session binding routes the Usage scope ahead of the pin and th
|
|
|
6281
6312
|
|
|
6282
6313
|
// S9: the config home each of these reads resolves, per case. The OS home is
|
|
6283
6314
|
// a temporary folder in every case, so no case can reach the real one.
|
|
6284
|
-
// "unset" and "empty"
|
|
6285
|
-
//
|
|
6286
|
-
// a bundled install (testing overlay) whose
|
|
6287
|
-
// user's own GENTLE_PI_CONFIG_HOME is then not
|
|
6315
|
+
// "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
|
|
6316
|
+
// counts as unset (#2201; it used to name the current folder). "bundled" stands
|
|
6317
|
+
// in for this package running from a bundled install (testing overlay) whose
|
|
6318
|
+
// prefix the launcher passed; the user's own GENTLE_PI_CONFIG_HOME is then not
|
|
6319
|
+
// this install's.
|
|
6288
6320
|
type ConfigHomeCase = "unset" | "empty" | "bundled";
|
|
6289
6321
|
function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
|
|
6290
6322
|
const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
|
|
6291
|
-
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
|
|
6323
|
+
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
|
|
6292
6324
|
const cwd = process.cwd();
|
|
6293
6325
|
process.env.HOME = join(base, "home");
|
|
6294
6326
|
process.env.USERPROFILE = join(base, "home");
|
|
6295
6327
|
delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
6296
6328
|
delete process.env.GENTLE_SHELL_PREFIX;
|
|
6329
|
+
delete process.env.GENTLE_SHELL_CONFIG_HOME;
|
|
6297
6330
|
t.after(() => {
|
|
6298
6331
|
process.chdir(cwd);
|
|
6299
6332
|
setBundledInstallForTesting(undefined);
|
|
@@ -6308,7 +6341,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
|
|
|
6308
6341
|
if (kind === "empty") {
|
|
6309
6342
|
mkdirSync(join(base, "cwd"));
|
|
6310
6343
|
process.chdir(join(base, "cwd"));
|
|
6311
|
-
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "
|
|
6344
|
+
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
|
|
6312
6345
|
}
|
|
6313
6346
|
const prefix = join(base, "prefix");
|
|
6314
6347
|
mkdirSync(prefix);
|
|
@@ -3,6 +3,7 @@ import test, { after, before } from "node:test";
|
|
|
3
3
|
import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
5
5
|
import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
|
|
6
|
+
import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
|
|
6
7
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
7
8
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
8
9
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
@@ -497,6 +498,166 @@ test("the system prompt carries only a byte-stable todo block; the current list
|
|
|
497
498
|
assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
|
|
498
499
|
});
|
|
499
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Drives the extension the way Pi's agent loop does (#2195): every model request
|
|
503
|
+
* runs the context handlers over the persisted transcript, and a custom message
|
|
504
|
+
* sent with triggerTurn false while streaming is appended to the transcript at
|
|
505
|
+
* turn_end, after the tool results (agent-session `_flushPendingCustomMessages`).
|
|
506
|
+
*/
|
|
507
|
+
function liveSession() {
|
|
508
|
+
const base = fakePi();
|
|
509
|
+
const queued: ContextMessage[] = [];
|
|
510
|
+
const pi = {
|
|
511
|
+
...base.pi,
|
|
512
|
+
sendMessage(message: { customType: string; content: string; display: boolean }, options: { triggerTurn?: boolean }) {
|
|
513
|
+
assert.equal(options.triggerTurn, false, "todo state must never start a turn");
|
|
514
|
+
queued.push({ role: "custom", customType: message.customType, content: message.content, display: message.display, timestamp: 0 });
|
|
515
|
+
},
|
|
516
|
+
} as unknown as ExtensionAPI;
|
|
517
|
+
gentleTodo(pi, {});
|
|
518
|
+
const { ctx } = fakeContext();
|
|
519
|
+
const transcript: ContextMessage[] = [{ role: "user", content: "Work on it", timestamp: 1 }];
|
|
520
|
+
const requests: ContextMessage[][] = [];
|
|
521
|
+
let call = 0;
|
|
522
|
+
const request = async () => {
|
|
523
|
+
const result = await base.fire("context", ctx, { messages: [...transcript] }) as { messages: ContextMessage[] } | undefined;
|
|
524
|
+
const messages = result?.messages ?? [...transcript];
|
|
525
|
+
requests.push(messages);
|
|
526
|
+
return messages;
|
|
527
|
+
};
|
|
528
|
+
/** One model request whose assistant reply calls `todo` (when params are given) or `read`. */
|
|
529
|
+
const turn = async (todo?: Record<string, unknown>) => {
|
|
530
|
+
await request();
|
|
531
|
+
call += 1;
|
|
532
|
+
const id = `call-${call}`;
|
|
533
|
+
const name = todo ? "todo" : "read";
|
|
534
|
+
const assistant = { role: "assistant", content: [{ type: "toolCall", id, name, arguments: todo ?? {} }], timestamp: 0 };
|
|
535
|
+
transcript.push(assistant);
|
|
536
|
+
if (todo) await base.tools.get("todo")!.execute(id, todo, undefined, undefined, ctx);
|
|
537
|
+
transcript.push({ role: "toolResult", content: [{ type: "text", text: "ok" }], timestamp: 0 });
|
|
538
|
+
await base.fire("turn_end", ctx, { message: assistant, toolResults: [] });
|
|
539
|
+
transcript.push(...queued.splice(0));
|
|
540
|
+
};
|
|
541
|
+
const assertAppendOnly = (from: number) => {
|
|
542
|
+
for (let index = from + 1; index < requests.length; index++) {
|
|
543
|
+
const previous = requests[index - 1]!;
|
|
544
|
+
assert.deepEqual(requests[index]!.slice(0, previous.length), previous, `request ${index} must extend request ${index - 1} byte for byte`);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const visibleState = (messages: ContextMessage[]) => [...messages].reverse().find((message) => message.customType === "gentle-todo-state")?.content;
|
|
548
|
+
return { ctx, fire: base.fire, transcript, requests, request, turn, assertAppendOnly, visibleState };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
test("a todo write in a running tool loop reaches the transcript before the next request, which stays append-only (#2195)", async () => {
|
|
552
|
+
const live = liveSession();
|
|
553
|
+
await live.fire("session_start", live.ctx);
|
|
554
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }, { title: "Ship it" }] });
|
|
555
|
+
await live.turn();
|
|
556
|
+
await live.turn();
|
|
557
|
+
await live.turn({ action: "update", id: 1, status: "done" });
|
|
558
|
+
await live.turn();
|
|
559
|
+
await live.request();
|
|
560
|
+
assert.match(live.visibleState(live.requests.at(-1)!) as string, /1\. \[done\] Fix the bug\n2\. \[pending\] Ship it/);
|
|
561
|
+
live.assertAppendOnly(0);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("closing every task does not leave a stale open list in the transcript (#2195)", async () => {
|
|
565
|
+
for (const close of [{ action: "update", id: 1, status: "done" }, { action: "update", id: 1, status: "dropped" }, { action: "clear" }]) {
|
|
566
|
+
const live = liveSession();
|
|
567
|
+
await live.fire("session_start", live.ctx);
|
|
568
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
|
|
569
|
+
await live.turn(close);
|
|
570
|
+
await live.turn();
|
|
571
|
+
const messages = await live.request();
|
|
572
|
+
assert.equal(live.visibleState(messages), "Current todo list: no open tasks.", `${JSON.stringify(close)}: the model must not see the closed task as still open`);
|
|
573
|
+
live.assertAppendOnly(0);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("an open list missing from the transcript (compaction, branch switch) is persisted again once, then requests stay append-only (#2195)", async () => {
|
|
578
|
+
const live = liveSession();
|
|
579
|
+
await live.fire("session_start", live.ctx);
|
|
580
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
|
|
581
|
+
// Compaction summarises the head of the conversation, including the persisted state.
|
|
582
|
+
live.transcript.splice(0, live.transcript.length, { role: "user", content: "Summary of earlier work", timestamp: 2 });
|
|
583
|
+
const healedFrom = live.requests.length;
|
|
584
|
+
await live.turn();
|
|
585
|
+
await live.turn();
|
|
586
|
+
await live.turn();
|
|
587
|
+
await live.request();
|
|
588
|
+
for (const messages of live.requests.slice(healedFrom)) assert.match(String(live.visibleState(messages)), /\[pending\] Fix the bug/);
|
|
589
|
+
assert.equal(live.transcript.filter((message) => message.customType === "gentle-todo-state").length, 1, "the missing state is persisted exactly once");
|
|
590
|
+
live.assertAppendOnly(healedFrom + 1);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("consecutive context requests with an unchanged Todo list preserve prefix stability (#2195)", async () => {
|
|
594
|
+
const { pi, tools, fire } = fakePi();
|
|
595
|
+
gentleTodo(pi, {});
|
|
596
|
+
const { ctx } = fakeContext();
|
|
597
|
+
await fire("session_start", ctx);
|
|
598
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
|
|
599
|
+
|
|
600
|
+
// Request 1: first model request
|
|
601
|
+
const userMsg = { role: "user", content: "Work on it", timestamp: 100 };
|
|
602
|
+
const req1 = await fire("context", ctx, { messages: [userMsg] }) as { messages: Array<{ role: string; customType?: string }> };
|
|
603
|
+
assert.ok(req1, "req1 must return context messages");
|
|
604
|
+
assert.ok(req1.messages.some((m) => (m as { customType?: string }).customType === "gentle-todo-state"), "req1 must include gentle-todo-state");
|
|
605
|
+
|
|
606
|
+
// Model executes a tool (read), producing assistant message and tool result
|
|
607
|
+
const assistantMsg = { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: {} }], timestamp: 200 };
|
|
608
|
+
const toolResultMsg = { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "file content" }], timestamp: 300 };
|
|
609
|
+
|
|
610
|
+
// Request 2: next model request with unchanged todo list.
|
|
611
|
+
// The input messages to context include the transcript with the already-delivered state message
|
|
612
|
+
const inputReq2 = [...req1.messages, assistantMsg, toolResultMsg];
|
|
613
|
+
const req2 = await fire("context", ctx, { messages: inputReq2 }) as { messages: Array<{ role: string; customType?: string }> } | undefined;
|
|
614
|
+
|
|
615
|
+
// req2 must not append a redundant trailing gentle-todo-state that would invalidate prefix caching
|
|
616
|
+
const effectiveReq2 = req2?.messages ?? inputReq2;
|
|
617
|
+
assert.deepEqual(
|
|
618
|
+
effectiveReq2.slice(0, req1.messages.length),
|
|
619
|
+
req1.messages,
|
|
620
|
+
"req1 must be an exact prefix of req2 to preserve prompt caching",
|
|
621
|
+
);
|
|
622
|
+
// No duplicate trailing state message at the end of req2
|
|
623
|
+
const stateMessages = effectiveReq2.filter((m) => (m as { customType?: string }).customType === "gentle-todo-state");
|
|
624
|
+
assert.equal(stateMessages.length, 1, "must not append duplicate gentle-todo-state when unchanged");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("todo write and update persist gentle-todo-state custom message to transcript (#2195)", async () => {
|
|
628
|
+
const sent: Array<{ message: unknown; options: unknown }> = [];
|
|
629
|
+
const base = fakePi();
|
|
630
|
+
const pi = {
|
|
631
|
+
...base.pi,
|
|
632
|
+
sendMessage(message: unknown, options: unknown) {
|
|
633
|
+
sent.push({ message, options });
|
|
634
|
+
},
|
|
635
|
+
} as unknown as ExtensionAPI;
|
|
636
|
+
gentleTodo(pi, {});
|
|
637
|
+
const { ctx } = fakeContext();
|
|
638
|
+
await base.fire("session_start", ctx);
|
|
639
|
+
|
|
640
|
+
// 1. todo write persists state
|
|
641
|
+
await base.tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Task 1" }] }, undefined, undefined, ctx);
|
|
642
|
+
assert.equal(sent.length, 1);
|
|
643
|
+
assert.deepEqual(sent[0]!.options, { triggerTurn: false });
|
|
644
|
+
const firstMsg = sent[0]!.message as { role: string; customType: string; content: string; display: boolean };
|
|
645
|
+
assert.equal(firstMsg.customType, "gentle-todo-state");
|
|
646
|
+
assert.equal(firstMsg.display, false);
|
|
647
|
+
assert.match(firstMsg.content, /1\. \[pending\] Task 1/);
|
|
648
|
+
|
|
649
|
+
// 2. todo update persists new state
|
|
650
|
+
await base.tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "doing" }, undefined, undefined, ctx);
|
|
651
|
+
assert.equal(sent.length, 2);
|
|
652
|
+
assert.deepEqual(sent[1]!.options, { triggerTurn: false });
|
|
653
|
+
const secondMsg = sent[1]!.message as { content: string };
|
|
654
|
+
assert.match(secondMsg.content, /1\. \[in_progress\] Task 1 — doing/);
|
|
655
|
+
|
|
656
|
+
// 3. todo list does NOT persist extra messages
|
|
657
|
+
await base.tools.get("todo")!.execute("c3", { action: "list" }, undefined, undefined, ctx);
|
|
658
|
+
assert.equal(sent.length, 2, "read actions must not emit state messages");
|
|
659
|
+
});
|
|
660
|
+
|
|
500
661
|
test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
|
|
501
662
|
const { pi, tools, fire } = fakePi();
|
|
502
663
|
gentleTodo(pi, {});
|
|
@@ -612,3 +773,21 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
|
|
|
612
773
|
}
|
|
613
774
|
assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
|
|
614
775
|
});
|
|
776
|
+
|
|
777
|
+
// Harness roadmap T6: the todo list is the session's progress, so the runtime
|
|
778
|
+
// publishes it for peers instead of asking the model to call orchestrator_session_id.
|
|
779
|
+
test("a todo write emits the session's progress; list and errors emit nothing", async () => {
|
|
780
|
+
const { pi, tools } = fakePi();
|
|
781
|
+
const emitted: Array<{ name: string; data: unknown }> = [];
|
|
782
|
+
Object.assign(pi, { events: { emit: (name: string, data: unknown) => { emitted.push({ name, data }); }, on: () => () => {} } });
|
|
783
|
+
gentleTodo(pi, {});
|
|
784
|
+
const { ctx } = fakeContext();
|
|
785
|
+
const todo = (params: unknown) => tools.get("todo")!.execute("call", params, undefined, undefined, ctx);
|
|
786
|
+
await todo({ action: "write", tasks: [{ title: "Write parser", status: "done" }, { title: "Wire CLI", status: "in_progress" }, { title: "Docs" }] });
|
|
787
|
+
assert.deepEqual(emitted, [{ name: TODO_PROGRESS_EVENT, data: { sessionId: "s1", done: 1, total: 3, current: "Wire CLI" } }]);
|
|
788
|
+
await todo({ action: "list" });
|
|
789
|
+
await todo({ action: "update", id: 99, status: "done" });
|
|
790
|
+
assert.equal(emitted.length, 1, "list and a failed update publish nothing");
|
|
791
|
+
await todo({ action: "update", id: 2, status: "done" });
|
|
792
|
+
assert.deepEqual(emitted.at(-1)?.data, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
793
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
4
|
+
import { INTERACTIVE_HOST_ENV } from "../lib/rpc-host.ts";
|
|
5
|
+
|
|
6
|
+
// Bench gate-p53-r1-BS40 x6 run 3 (headless RPC): the ask-on-risk delivery
|
|
7
|
+
// question ran before any code was written; ask_user_question answered
|
|
8
|
+
// "unavailable outside the interactive TUI", the orchestrator printed the menu
|
|
9
|
+
// and stopped, and the round ended with "No code changed yet". When no human
|
|
10
|
+
// can answer, the delivery question resolves to the recommended chain instead.
|
|
11
|
+
|
|
12
|
+
const HEADLESS_DELIVERY = "No human can answer questions in this session";
|
|
13
|
+
|
|
14
|
+
function promptFor(mode: string | undefined): string {
|
|
15
|
+
return __testing.buildGentlePrompt("neutral", process.cwd(), undefined, undefined, mode);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function withInteractiveHost<T>(value: string | undefined, run: () => T): T {
|
|
19
|
+
const previous = process.env[INTERACTIVE_HOST_ENV];
|
|
20
|
+
if (value === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
21
|
+
else process.env[INTERACTIVE_HOST_ENV] = value;
|
|
22
|
+
try {
|
|
23
|
+
return run();
|
|
24
|
+
} finally {
|
|
25
|
+
if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
26
|
+
else process.env[INTERACTIVE_HOST_ENV] = previous;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("headless host modes resolve the ask-on-risk delivery question to the feature-branch chain without asking", () => {
|
|
31
|
+
withInteractiveHost(undefined, () => {
|
|
32
|
+
for (const mode of ["rpc", "print", "json"]) {
|
|
33
|
+
const prompt = promptFor(mode);
|
|
34
|
+
assert.ok(prompt.includes(HEADLESS_DELIVERY), `${mode} must say no human can answer`);
|
|
35
|
+
assert.match(prompt, /ask-on-risk/, `${mode} must name the ask-on-risk delivery question`);
|
|
36
|
+
assert.match(prompt, /`feature-branch-chain`/, `${mode} must name the chain it resolves to`);
|
|
37
|
+
assert.match(prompt, /feature document/, `${mode} must record the choice in the feature document`);
|
|
38
|
+
assert.match(prompt, /consent/, `${mode} must keep consent prompts out of the default`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("interactive hosts keep asking the delivery question", () => {
|
|
44
|
+
withInteractiveHost(undefined, () => {
|
|
45
|
+
for (const mode of ["tui", undefined]) assert.ok(!promptFor(mode).includes(HEADLESS_DELIVERY), `${String(mode)} keeps the question`);
|
|
46
|
+
});
|
|
47
|
+
withInteractiveHost("1", () => {
|
|
48
|
+
assert.ok(!promptFor("rpc").includes(HEADLESS_DELIVERY), "an interactive RPC host keeps the question");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the reference documents the headless delivery default", async () => {
|
|
53
|
+
const { readFileSync } = await import("node:fs");
|
|
54
|
+
const reference = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
|
|
55
|
+
for (const term of ["no human can answer questions", "`GENTLE_SHELL_INTERACTIVE_HOST=1`", "`ask-on-risk` does not ask", "`feature-branch-chain`"]) {
|
|
56
|
+
assert.ok(reference.includes(term), `docs/readme-reference.md is missing ${term}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -4,7 +4,7 @@ import childProcess from "node:child_process";
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { syncBuiltinESMExports } from "node:module";
|
|
6
6
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
import { activitySummary, ActivityPublisher, createActivityColumns, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts";
|
|
7
|
+
import { activitySummary, ActivityPublisher, createActivityColumns, herdrDetected, herdrSetupHint, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts";
|
|
8
8
|
|
|
9
9
|
const tasks = [{ title: "Revisar integración Herdr", status: "in_progress" }];
|
|
10
10
|
const project = (title: string, columns = 30) => activitySummary([{ title, status: "in_progress" }], columns);
|
|
@@ -241,3 +241,36 @@ test("geometry is cached, local/named, bounded and fail-safe without reading use
|
|
|
241
241
|
assert.equal(paths.length, before, "relative paths must not read arbitrary snapshots");
|
|
242
242
|
} finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); }
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
const herdrEnv = { HERDR_ENV: "1", HERDR_PANE_ID: "pane", HERDR_SOCKET_PATH: "/herdr.sock" };
|
|
246
|
+
|
|
247
|
+
test("Herdr detection uses the activity gate: HERDR_ENV=1 plus nonblank pane and socket", () => {
|
|
248
|
+
assert.equal(herdrDetected(herdrEnv), true);
|
|
249
|
+
for (const env of [{}, { ...herdrEnv, HERDR_ENV: "0" }, { ...herdrEnv, HERDR_PANE_ID: " " }, { ...herdrEnv, HERDR_SOCKET_PATH: "" }]) {
|
|
250
|
+
assert.equal(herdrDetected(env), false);
|
|
251
|
+
assert.deepEqual(herdrSetupHint(env, { interactive: true, isSocket: () => true }), []);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("setup hint states active publishing and gives read-only sidebar guidance", () => {
|
|
256
|
+
const sockets: string[] = [];
|
|
257
|
+
const lines = herdrSetupHint(herdrEnv, { interactive: true, isSocket: (path) => { sockets.push(path); return true; } });
|
|
258
|
+
assert.deepEqual(sockets, ["/herdr.sock"]);
|
|
259
|
+
assert.deepEqual(lines, [
|
|
260
|
+
"Herdr",
|
|
261
|
+
"pass: Herdr task-summary publishing active",
|
|
262
|
+
"info: Herdr's default sidebar does not show task titles; add these two rows to the Agents sidebar rows in your Herdr config: ['$summary'], ['$summary2']",
|
|
263
|
+
"info: Sidebar rows belong to the config of the Herdr client that displays the session, including when Pi runs over SSH",
|
|
264
|
+
"info: After editing, reload or restart Herdr; Gentle Shell never changes Herdr config",
|
|
265
|
+
"info: Details: https://github.com/Gentleman-Programming/gentle-shell/blob/main/docs/readme-reference.md#herdr-active-work-summary",
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("setup hint names why publishing is inactive and keeps the guidance", () => {
|
|
270
|
+
const reason = (env: NodeJS.ProcessEnv, interactive: boolean, socket: boolean) =>
|
|
271
|
+
herdrSetupHint(env, { interactive, isSocket: () => socket })[1];
|
|
272
|
+
assert.equal(reason({ ...herdrEnv, GENTLE_PI_AGENTS_CHILD: "1" }, true, true), "warn: Herdr task-summary publishing inactive: Gentle Agents child session");
|
|
273
|
+
assert.equal(reason(herdrEnv, false, true), "warn: Herdr task-summary publishing inactive: requires the interactive Pi TUI");
|
|
274
|
+
assert.equal(reason(herdrEnv, true, false), "warn: Herdr task-summary publishing inactive: HERDR_SOCKET_PATH is not a Unix socket");
|
|
275
|
+
assert.equal(herdrSetupHint(herdrEnv, { interactive: false, isSocket: () => true }).length, 6);
|
|
276
|
+
});
|
|
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
|
|
|
306
306
|
const view = await plan(port, await login());
|
|
307
307
|
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
|
|
308
308
|
assert.equal(view.blockers[0].guidance,
|
|
309
|
-
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.
|
|
310
|
-
"Update it with `pnpm add -g gentle-pi@4.
|
|
309
|
+
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.3.0 or newer. Nothing was replaced. " +
|
|
310
|
+
"Update it with `pnpm add -g gentle-pi@4.3.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
|
|
311
311
|
assert.deepEqual(view.actions, []);
|
|
312
312
|
} finally {
|
|
313
313
|
await host.close("test");
|
|
@@ -934,6 +934,10 @@ test("a failed setup passes only its bounded detail through, with rate-limit gui
|
|
|
934
934
|
for (const [step, detail, expected] of [
|
|
935
935
|
["install-global", ".../gentle-pi postinstall: root cause Error: go: open ~/x: The directory name is invalid.", guidance.failed["install-global"]],
|
|
936
936
|
["install-global", rateLimited, guidance.githubRateLimit],
|
|
937
|
+
// Windows code integrity (Smart App Control) refused the gentle-ai.exe the postinstall built.
|
|
938
|
+
["install-global", ".../node_modules/gentle-pi postinstall: caused by Error: spawn UNKNOWN (code UNKNOWN)", guidance.codeIntegrityBlocked],
|
|
939
|
+
["install-version", ".../node_modules/gentle-pi postinstall: caused by Error: spawn UNKNOWN (code UNKNOWN)", guidance.codeIntegrityBlocked],
|
|
940
|
+
["install-version", "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized", guidance.failed["install-version"]],
|
|
937
941
|
["install-shell-main", mainLimited, guidance.githubRateLimit],
|
|
938
942
|
["install-shell-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-shell (HTTP 500)", guidance.failed["install-shell-main"]],
|
|
939
943
|
["build-gentle-ai-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-ai (HTTP 403)", guidance.githubRateLimit],
|
|
@@ -957,6 +961,10 @@ test("a failed setup passes only its bounded detail through, with rate-limit gui
|
|
|
957
961
|
assert.match(guidance.githubRateLimit, /GitHub/);
|
|
958
962
|
assert.match(guidance.githubRateLimit, /hour/);
|
|
959
963
|
assert.doesNotMatch(guidance.githubRateLimit, /gentle-shell setup|token|credential|password|log in|sign in/i);
|
|
964
|
+
assert.match(guidance.codeIntegrityBlocked, /Smart App Control/);
|
|
965
|
+
assert.match(guidance.codeIntegrityBlocked, /WSL/);
|
|
966
|
+
assert.match(guidance.codeIntegrityBlocked, /cannot be turned back on/);
|
|
967
|
+
assert.doesNotMatch(guidance.codeIntegrityBlocked, /network|registry/);
|
|
960
968
|
assert.match(guidance.goDestinationConflict, /earlier/);
|
|
961
969
|
assert.match(guidance.goDestinationConflict, /Remove that folder/);
|
|
962
970
|
assert.doesNotMatch(guidance.goDestinationConflict, /network/);
|
|
@@ -653,13 +653,14 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
|
|
|
653
653
|
const scripts = join(f.root, "scripts"); mkdirSync(scripts);
|
|
654
654
|
// Observe the copied production entry's first PowerShell handle so the
|
|
655
655
|
// existing ownership-checked guard can reap it if the fixture times out.
|
|
656
|
-
writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8")));
|
|
656
|
+
writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"), true));
|
|
657
657
|
const local = join(f.root, "home"); mkdirSync(local);
|
|
658
658
|
const env = { ...nativeEnv(f.root), LOCALAPPDATA: local };
|
|
659
659
|
// The same outer bound as every other production fragment (nativeCmd): a cold
|
|
660
660
|
// cmd.exe plus Windows PowerShell 5.1 start can exceed 5 s while the CI step runs
|
|
661
661
|
// the installer suites in parallel. The bundle stage itself has no deadline.
|
|
662
662
|
console.error("WINDOWS_ENTRY_PROBE_START timeoutMs=14000");
|
|
663
|
+
const startedAt = Date.now();
|
|
663
664
|
const started = performance.now();
|
|
664
665
|
const result = await nativeCmdFile(f.root, "scripts\\bootstrap.cmd", env, 14000);
|
|
665
666
|
console.error("WINDOWS_ENTRY_PROBE_RESULT", JSON.stringify({ elapsedMs: performance.now() - started, status: result.status, guardKilled: result.guardKilled, expectedDiagnostic: /No acquisition attempted/.test(result.stderr) }));
|
|
@@ -668,6 +669,8 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
|
|
|
668
669
|
assertNative(result, 1);
|
|
669
670
|
assert.match(result.stderr, /No acquisition attempted/);
|
|
670
671
|
assert.match(readFileSync(join(f.root, "process-records"), "utf8").trim(), /^[1-9][0-9]*\|[1-9][0-9]*$/, "the copied entry must expose its first PowerShell handle to fixture cleanup");
|
|
672
|
+
const readyMs = Number(readFileSync(join(f.root, "powershell-ready-ms"), "utf8"));
|
|
673
|
+
assert.ok(Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= Date.now(), "the fixture must timestamp PowerShell executing its body");
|
|
671
674
|
assert.deepEqual(readdirSync(local), []);
|
|
672
675
|
} finally {
|
|
673
676
|
try { cleanNativeProcesses(f.root); } finally { f.cleanup(); }
|
|
@@ -1199,11 +1202,14 @@ test("production and fixture PowerShell never depend on autoloading Microsoft.Po
|
|
|
1199
1202
|
}
|
|
1200
1203
|
});
|
|
1201
1204
|
const psRecordLine = ' "$record = [Diagnostics.Process]::GetCurrentProcess(); [IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$record.Id + [char]124 + [string]$record.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^';
|
|
1202
|
-
|
|
1205
|
+
// Entry-only telemetry: this timestamps execution of the PowerShell body, not
|
|
1206
|
+
// merely process creation. Keep it separate from the guard's PID/ticks records.
|
|
1207
|
+
const psReadyLine = ' "[IO.File]::WriteAllText([IO.Path]::Combine($env:GENTLE_FIXTURE_ROOT,\'powershell-ready-ms\'),[string][DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds());" ^';
|
|
1208
|
+
function observeStage(stage: string, entryReadiness = false) {
|
|
1203
1209
|
const lines = stage.split("\n");
|
|
1204
1210
|
const index = lines.findIndex((line) => line.includes("LanguageMode"));
|
|
1205
1211
|
assert.ok(index >= 0);
|
|
1206
|
-
lines.splice(index + 1, 0, psRecordLine);
|
|
1212
|
+
lines.splice(index + 1, 0, psRecordLine, ...(entryReadiness ? [psReadyLine] : []));
|
|
1207
1213
|
return lines.join("\n");
|
|
1208
1214
|
}
|
|
1209
1215
|
function cmdComposition(stages: string[]) {
|
|
@@ -1234,6 +1240,16 @@ test("native entry observation preserves the complete production batch", () => {
|
|
|
1234
1240
|
assert.equal(observed.filter((line) => line !== psRecordLine).join("\n"), source);
|
|
1235
1241
|
});
|
|
1236
1242
|
|
|
1243
|
+
test("native entry readiness observation is opt-in and preserves production and ownership records", () => {
|
|
1244
|
+
const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8");
|
|
1245
|
+
const observed = observeStage(source, true).split("\n");
|
|
1246
|
+
assert.equal(observed.filter((line) => line.includes("powershell-ready-ms")).length, 1);
|
|
1247
|
+
assert.equal(observed.filter((line) => line === psRecordLine).length, 1);
|
|
1248
|
+
assert.equal(observed.filter((line) => line !== psRecordLine && !line.includes("powershell-ready-ms")).join("\n"), source);
|
|
1249
|
+
assert.doesNotMatch(observeStage(source), /powershell-ready-ms/);
|
|
1250
|
+
assert.ok(observed.findIndex((line) => line.includes("powershell-ready-ms")) > observed.indexOf(psRecordLine));
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1237
1253
|
test("native fixture compositions retain exact production fragments and no transport command", () => {
|
|
1238
1254
|
for (const marker of Object.values(stageMarkers)) {
|
|
1239
1255
|
const original = cmdStage(marker);
|
|
@@ -1314,10 +1330,22 @@ async function nativeCmd(root: string, stages: string[], env: NodeJS.ProcessEnv
|
|
|
1314
1330
|
return nativeCmdFile(root, "fixture.cmd", env, limit);
|
|
1315
1331
|
}
|
|
1316
1332
|
async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv, limit = 5000): Promise<NativeResult> {
|
|
1333
|
+
const startedAt = Date.now();
|
|
1317
1334
|
const started = performance.now();
|
|
1318
1335
|
const trace = (phase: string, detail: Record<string, unknown> = {}) => {
|
|
1319
1336
|
if (file === "scripts\\bootstrap.cmd") console.error("WINDOWS_ENTRY_PHASE", JSON.stringify({ phase, elapsedMs: performance.now() - started, limit, ...detail }));
|
|
1320
1337
|
};
|
|
1338
|
+
// Wall-clock timestamps share an epoch with PowerShell. These measurements
|
|
1339
|
+
// are diagnostic only; the monotonic outer deadline is never reset. Emit
|
|
1340
|
+
// the split before synchronous guard cleanup can inflate execution time.
|
|
1341
|
+
const traceEntryTiming = () => {
|
|
1342
|
+
if (file !== "scripts\\bootstrap.cmd") return;
|
|
1343
|
+
const readyPath = join(root, "powershell-ready-ms");
|
|
1344
|
+
const finishedAt = Date.now();
|
|
1345
|
+
const readyMs = existsSync(readyPath) ? Number(readFileSync(readyPath, "utf8")) : NaN;
|
|
1346
|
+
const valid = Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= finishedAt;
|
|
1347
|
+
trace("entry-timing", { readyObserved: valid, startupMs: valid ? readyMs - startedAt : null, executionMs: valid ? finishedAt - readyMs : null });
|
|
1348
|
+
};
|
|
1321
1349
|
trace("spawn-request");
|
|
1322
1350
|
const child = spawn(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", file], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1323
1351
|
let stdout = ""; let stderr = ""; let guardKilled = false;
|
|
@@ -1327,6 +1355,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
|
|
|
1327
1355
|
const result = await new Promise<NativeResult>((resolveChild, reject) => {
|
|
1328
1356
|
const timer = setTimeout(() => {
|
|
1329
1357
|
guardKilled = true;
|
|
1358
|
+
traceEntryTiming();
|
|
1330
1359
|
trace("guard-fired", { recordExists: existsSync(join(root, "process-records")), diagnosticSeen: /No acquisition attempted/.test(stderr) });
|
|
1331
1360
|
try { cleanNativeProcesses(root); } catch (error) { guardError = error; }
|
|
1332
1361
|
child.kill("SIGKILL");
|
|
@@ -1341,7 +1370,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
|
|
|
1341
1370
|
if (!/No acquisition attempted/.test(before) && /No acquisition attempted/.test(stderr)) trace("missing-bundle-diagnostic");
|
|
1342
1371
|
if (stderr.length > 1048576) { guardError = new Error("Fixture output limit"); child.kill("SIGKILL"); } });
|
|
1343
1372
|
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
1344
|
-
child.once("close", (status) => { trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
|
|
1373
|
+
child.once("close", (status) => { if (!guardKilled) traceEntryTiming(); trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
|
|
1345
1374
|
});
|
|
1346
1375
|
const residualReaped = cleanNativeProcesses(root);
|
|
1347
1376
|
assert.equal(residualReaped, false, "production must reap its own recorded children; fixture cleanup cannot mask a failure");
|
|
@@ -379,11 +379,17 @@ test("4.1.1 explicitly repeats 4.0.0 because capabilities/v2.6 is unchanged desp
|
|
|
379
379
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.0.0"] as Record<string, boolean>);
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
test("4.2.0 explicitly repeats 4.1.1 because capabilities/v2.6 is unchanged despite provider contract 1.4.0", () => {
|
|
383
|
+
const contract = NATIVE_CLI_CONTRACTS["4.2.0"] as Record<string, boolean>;
|
|
384
|
+
assert.ok(contract);
|
|
385
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.1.1"] as Record<string, boolean>);
|
|
386
|
+
});
|
|
387
|
+
|
|
382
388
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
383
389
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
384
390
|
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
385
391
|
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
386
392
|
// so they get no row: a row asserts ground truth measured against a binary
|
|
387
393
|
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
388
|
-
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1"]);
|
|
394
|
+
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1", "4.2.0"]);
|
|
389
395
|
});
|
|
@@ -3,7 +3,8 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import test from "node:test";
|
|
6
|
+
import test, { after, before } from "node:test";
|
|
7
|
+
import { setWindowsAclAuthorityForTesting } from "../lib/review-candidate-view-owner.ts";
|
|
7
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { __testing, createGentleAiExtension, PendingReviewConsentRegistry } from "../extensions/gentle-ai.ts";
|
|
9
10
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
@@ -28,6 +29,24 @@ import {
|
|
|
28
29
|
type ReviewStatusV3,
|
|
29
30
|
} from "../lib/review-integration-v2.ts";
|
|
30
31
|
|
|
32
|
+
// Coordination tests use fixture authority; real Windows ACL coverage lives
|
|
33
|
+
// in review-candidate-view.test.ts, in a separate test process.
|
|
34
|
+
let fixtureAclChecks = 0;
|
|
35
|
+
before(() => {
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
setWindowsAclAuthorityForTesting(() => { fixtureAclChecks++; });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
after(() => setWindowsAclAuthorityForTesting());
|
|
41
|
+
|
|
42
|
+
test("coordination suites isolate Windows ACL authority with teardown", () => {
|
|
43
|
+
for (const file of ["native-review-parity", "review-controller-native-routing", "review-session-standing-permission-controller"]) {
|
|
44
|
+
const source = readFileSync(new URL(`./${file}.test.ts`, import.meta.url), "utf8");
|
|
45
|
+
assert.match(source, /import \{ setWindowsAclAuthorityForTesting \} from/, file);
|
|
46
|
+
assert.match(source, /after\(\(\) => setWindowsAclAuthorityForTesting\(\)\)/, file);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
31
50
|
const CAPTURED_FIXTURES = join(process.cwd(), "tests", "fixtures", "devbinary");
|
|
32
51
|
|
|
33
52
|
function captured(name: string): unknown {
|
|
@@ -218,7 +237,9 @@ function repository(t: test.TestContext): string {
|
|
|
218
237
|
|
|
219
238
|
function startStatus(cwd: string): ReviewStatusV3 {
|
|
220
239
|
const candidates = new CandidateViewRegistry();
|
|
240
|
+
const checksBefore = fixtureAclChecks;
|
|
221
241
|
const candidate = candidates.create({ contributorRoot: cwd });
|
|
242
|
+
if (process.platform === "win32") assert.ok(fixtureAclChecks > checksBefore, "coordination candidates must use the fixture ACL authority");
|
|
222
243
|
try {
|
|
223
244
|
return {
|
|
224
245
|
contract: "gentle-ai.review-integration/v2",
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
5
6
|
|
|
6
7
|
// gentle-shell#1731 T13 (evidence L24): in bench task x4 the user wrote "I may
|
|
7
8
|
// stop you between the two and pick the second part up later"; every Gentle
|
|
@@ -62,7 +63,8 @@ test("T13: the Close gate requires continuing or quoting the user's explicit sto
|
|
|
62
63
|
|
|
63
64
|
test("T13: budgets hold and the pinned human-control line is untouched", () => {
|
|
64
65
|
const bytes = Buffer.byteLength(tracking, "utf8");
|
|
65
|
-
|
|
66
|
+
// S14: close the open slice before a high-risk commit; outcomes per commit (12,600 -> 12,850 B), same as MODULE_BUDGETS.
|
|
67
|
+
assert.ok(bytes <= 12_850, `orchestrator-tracking.md is ${bytes} B, over its 12,850 B budget`);
|
|
66
68
|
assert.ok(core.includes("- Preserve human control: user decisions beat agent momentum."), "core human-control line must stay verbatim");
|
|
67
69
|
assert.ok(core.includes("4. **Track**") && core.includes("`orchestrator-tracking.md`"), "Track must still load the tracking module");
|
|
68
70
|
});
|
|
@@ -70,7 +72,8 @@ test("T13: budgets hold and the pinned human-control line is untouched", () => {
|
|
|
70
72
|
test("T13: the always-on harness narrows conditional intent and gates Close on pending tasks", () => {
|
|
71
73
|
// The harness ODD steps are injected on every request, including the small
|
|
72
74
|
// path that never loads the tracking module, so the narrowing must live there too.
|
|
73
|
-
|
|
75
|
+
// They are rendered from the mirrored routing/pi.md, so check the rendered harness.
|
|
76
|
+
const harness = __testing.buildGentlePrompt("gentleman");
|
|
74
77
|
assert.ok(
|
|
75
78
|
harness.includes("Ambiguous or conditional change intent (unclear whether a change is authorized at all) gets one clarification; stop and wait."),
|
|
76
79
|
"harness Authorize step must narrow conditional intent",
|