gentle-pi 3.1.0 → 3.2.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/assets/orchestrator-delegation.md +6 -4
- package/assets/orchestrator-memory.md +1 -1
- package/assets/sdd-orchestrator-workflow.md +1 -1
- package/docs/gentle-shell.md +1 -1
- package/docs/readme-reference.md +43 -18
- package/extensions/gentle-agents.ts +22 -1
- package/extensions/gentle-ai.ts +33 -138
- package/extensions/pi-pretty.ts +13 -1
- package/lib/background-subagents-policy.ts +148 -0
- package/lib/native-review-cli.ts +9 -0
- package/lib/review-candidate-view.ts +27 -6
- package/lib/sdd-preflight.ts +5 -19
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/skills/chained-pr/SKILL.md +2 -1
- package/skills/work-unit-commits/SKILL.md +9 -0
- package/tests/background-subagents-default-mode.test.ts +105 -0
- package/tests/gentle-agents.test.ts +14 -1
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-theme.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +14 -1
- package/tests/odd-routing-contract.test.ts +49 -2
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-pretty.test.ts +5 -0
- package/tests/review-base-ref-hint.test.ts +39 -0
- package/tests/review-candidate-view.test.ts +55 -0
- package/tests/runtime-harness.mjs +12 -10
- package/tests/sdd-managed-runtime-settlement.test.ts +2 -2
- package/tests/sdd-preflight-rpc-input.test.ts +29 -29
- package/tests/sdd-preflight.test.ts +12 -7
- package/tests/windows-hidden-processes.test.ts +2 -2
- package/themes/Gentleman-Cute.json +1 -1
|
@@ -10,6 +10,7 @@ import { join, resolve } from "node:path";
|
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import { gzipSync } from "node:zlib";
|
|
12
12
|
import {
|
|
13
|
+
BASE_REF_ACCEPTED_FORMS,
|
|
13
14
|
CandidateViewRegistry,
|
|
14
15
|
CandidateViewError,
|
|
15
16
|
hasExpectedExecutableBits,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
digestChangedPathManifest,
|
|
21
22
|
injectReviewCandidateView,
|
|
22
23
|
readCandidateContextManifestPage,
|
|
24
|
+
resolveCanonicalCandidateBase,
|
|
23
25
|
type NativeCandidateProjectionDescriptor,
|
|
24
26
|
} from "../lib/review-candidate-view.ts";
|
|
25
27
|
import { assertCandidateOwnerParent, assertTrustedWindowsOwner, prepareCandidateOwnerParent, setWindowsAclAuthorityForTesting, validatePrivateWindowsDacl, validatePrivateWindowsOwner, WindowsDaclValidationError, WindowsOwnerValidationError } from "../lib/review-candidate-view-owner.ts";
|
|
@@ -653,6 +655,59 @@ test("explicit base resolution preserves structured for-each-ref output-limit di
|
|
|
653
655
|
assert.doesNotMatch(failure.message, /sensitive base-reference output/);
|
|
654
656
|
});
|
|
655
657
|
|
|
658
|
+
test("resolving an abbreviated commit id as an explicit base ref names the accepted forms", (t) => {
|
|
659
|
+
const contributorRoot = repository(t);
|
|
660
|
+
const fullId = git(contributorRoot, "rev-parse", "HEAD");
|
|
661
|
+
const abbreviated = fullId.slice(0, 7);
|
|
662
|
+
assert.throws(
|
|
663
|
+
() => resolveCanonicalCandidateBase(contributorRoot, abbreviated),
|
|
664
|
+
(error: unknown) => {
|
|
665
|
+
assert.ok(error instanceof CandidateViewError);
|
|
666
|
+
assert.equal(error.reason, "base-ref-unresolvable");
|
|
667
|
+
assert.match(error.message, /accepted forms/);
|
|
668
|
+
assert.ok(error.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
669
|
+
assert.match(error.message, /abbreviated commit ids are not accepted/);
|
|
670
|
+
return true;
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("resolving an ambiguous explicit base ref names the accepted forms", (t) => {
|
|
676
|
+
const contributorRoot = repository(t);
|
|
677
|
+
git(contributorRoot, "tag", "dupe");
|
|
678
|
+
git(contributorRoot, "branch", "dupe");
|
|
679
|
+
assert.throws(
|
|
680
|
+
() => resolveCanonicalCandidateBase(contributorRoot, "dupe"),
|
|
681
|
+
(error: unknown) => {
|
|
682
|
+
assert.ok(error instanceof CandidateViewError);
|
|
683
|
+
assert.equal(error.reason, "base-ref-ambiguous");
|
|
684
|
+
assert.match(error.message, /accepted forms/);
|
|
685
|
+
assert.ok(error.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
686
|
+
return true;
|
|
687
|
+
},
|
|
688
|
+
);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("resolving an explicit base ref that names a tree, not a commit, reports it as unresolvable with the accepted forms instead of a generic git failure", (t) => {
|
|
692
|
+
const contributorRoot = repository(t);
|
|
693
|
+
const treeId = "b".repeat(40);
|
|
694
|
+
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
695
|
+
if (arguments_.at(-1) === `${treeId}^{commit}`) throw Object.assign(new Error("fatal: not a commit"), { status: 128, stdout: Buffer.from("") });
|
|
696
|
+
return execFileSync(file, arguments_, options);
|
|
697
|
+
};
|
|
698
|
+
let failure: unknown;
|
|
699
|
+
try {
|
|
700
|
+
new CandidateViewRegistry(executor).create({ contributorRoot, baseRef: treeId });
|
|
701
|
+
} catch (error) {
|
|
702
|
+
failure = error;
|
|
703
|
+
}
|
|
704
|
+
assert.ok(failure instanceof CandidateViewError);
|
|
705
|
+
assert.equal(failure.reason, "base-ref-unresolvable");
|
|
706
|
+
assert.match(failure.message, /it does not name a commit/);
|
|
707
|
+
assert.match(failure.message, /accepted forms/);
|
|
708
|
+
assert.ok(failure.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
709
|
+
});
|
|
710
|
+
|
|
656
711
|
test("every synchronous candidate-view Git command receives the explicit 64 MiB output limit", (t) => {
|
|
657
712
|
const calls: Array<{ arguments: readonly string[]; maxBuffer: number | undefined }> = [];
|
|
658
713
|
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
@@ -1250,24 +1250,26 @@ async function run() {
|
|
|
1250
1250
|
await inputHook({ text: "vamos con sdd", source: "interactive" }, ctx),
|
|
1251
1251
|
{ action: "continue" },
|
|
1252
1252
|
);
|
|
1253
|
-
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
|
|
1254
|
-
assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
|
|
1255
|
-
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
|
|
1256
|
-
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
|
|
1257
|
-
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), false);
|
|
1258
|
-
assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
|
|
1259
|
-
assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
|
|
1260
|
-
assert.equal(ctx.ui.selections.length, 1, "first interactive SDD trigger confirms session suggestions");
|
|
1261
|
-
assert.match(ctx.ui.notifications.at(-1).message, /Preference source: explicit session choice/);
|
|
1262
1253
|
assert.deepEqual(
|
|
1263
1254
|
await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
|
|
1264
1255
|
{ action: "continue" },
|
|
1265
1256
|
);
|
|
1266
|
-
assert.equal(
|
|
1257
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
|
|
1258
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
|
|
1259
|
+
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), false);
|
|
1260
|
+
assert.equal(ctx.ui.selections.length, 0, "natural-language SDD text has no input-hook side effect");
|
|
1261
|
+
|
|
1267
1262
|
assert.deepEqual(
|
|
1268
1263
|
await inputHook({ text: "/sdd", source: "interactive" }, ctx),
|
|
1269
1264
|
{ action: "continue" },
|
|
1270
1265
|
);
|
|
1266
|
+
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
|
|
1267
|
+
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-status.md")), true);
|
|
1268
|
+
assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), false);
|
|
1269
|
+
assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
|
|
1270
|
+
assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
|
|
1271
|
+
assert.equal(ctx.ui.selections.length, 1, "first slash SDD trigger confirms session suggestions");
|
|
1272
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Preference source: explicit session choice/);
|
|
1271
1273
|
assert.deepEqual(
|
|
1272
1274
|
await inputHook({ text: "/sdd plan", source: "interactive" }, ctx),
|
|
1273
1275
|
{ action: "continue" },
|
|
@@ -75,9 +75,9 @@ test("remediation actor preserves separately authorized memory artifact tools",
|
|
|
75
75
|
|
|
76
76
|
test("R1 confirms exact canonical paths and commands; data, denial and symlinks grant nothing", async t => {
|
|
77
77
|
const { confirmRemediationScope, remediationToolAllowed } = await import("../extensions/gentle-agents.ts");
|
|
78
|
-
const { mkdtempSync, writeFileSync, symlinkSync, rmSync } = await import("node:fs");
|
|
78
|
+
const { mkdtempSync, realpathSync, writeFileSync, symlinkSync, rmSync } = await import("node:fs");
|
|
79
79
|
const { tmpdir } = await import("node:os"); const { join } = await import("node:path");
|
|
80
|
-
const cwd = mkdtempSync(join(tmpdir(), "remediation-scope-")); t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
80
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "remediation-scope-"))); t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
81
81
|
const target = join(cwd, "allowed.ts"); writeFileSync(target, "original"); symlinkSync(target, join(cwd, "alias.ts"));
|
|
82
82
|
const candidate = { ...plan, cwd, editPaths: [target] }, native = { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] };
|
|
83
83
|
let shown = "", confirmations = 0;
|
|
@@ -8,17 +8,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
8
8
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
9
|
import { isSddPreflightTrigger, sddPreflightDiskPath } from "../lib/sdd-preflight.ts";
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// calls, and a stall that only surfaced as the runner's inactivity timeout.
|
|
18
|
-
//
|
|
19
|
-
// An RPC child consumes the parent-rendered preflight block transported in its
|
|
20
|
-
// task context. It must never originate preflight, and the interceptor must
|
|
21
|
-
// never consume a delegated prompt on its behalf.
|
|
11
|
+
// The `input` hook is syntax-only: slash SDD commands may originate preflight
|
|
12
|
+
// in an interactive parent, while ordinary natural-language text always reaches
|
|
13
|
+
// the model. Natural-language SDD selection belongs to the parent/orchestrator;
|
|
14
|
+
// dispatch and before_agent_start gates enforce preflight at the action boundary.
|
|
15
|
+
// RPC children consume the parent-rendered preflight block transported in task
|
|
16
|
+
// context and never originate preflight.
|
|
22
17
|
|
|
23
18
|
type InputResult = { action: "continue" | "handled" };
|
|
24
19
|
type InputHandler = (event: { text?: unknown }, ctx: ExtensionContext) => Promise<InputResult>;
|
|
@@ -52,13 +47,11 @@ function ctx(overrides: Record<string, unknown>): ExtensionContext {
|
|
|
52
47
|
} as unknown as ExtensionContext;
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
test("
|
|
56
|
-
|
|
57
|
-
// this text they would pass for the wrong reason.
|
|
58
|
-
assert.equal(isSddPreflightTrigger(DELEGATED_SDD_TASK), true);
|
|
50
|
+
test("natural-language SDD task text bypasses the input preflight interceptor", () => {
|
|
51
|
+
assert.equal(isSddPreflightTrigger(DELEGATED_SDD_TASK), false);
|
|
59
52
|
});
|
|
60
53
|
|
|
61
|
-
test("an RPC child's
|
|
54
|
+
test("an RPC child's natural-language SDD prompt is not consumed by the input hook", async () => {
|
|
62
55
|
const input = inputHook();
|
|
63
56
|
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-rpc-input-"));
|
|
64
57
|
const notifications: string[] = [];
|
|
@@ -95,27 +88,34 @@ test("an RPC child's ordinary prompt still continues", async () => {
|
|
|
95
88
|
}
|
|
96
89
|
});
|
|
97
90
|
|
|
98
|
-
test("an interactive parent
|
|
91
|
+
test("an interactive parent's natural-language SDD request has no input-hook side effect", async () => {
|
|
99
92
|
const input = inputHook();
|
|
100
|
-
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-parent-input-"));
|
|
93
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-parent-natural-input-"));
|
|
94
|
+
try {
|
|
95
|
+
const result = await input(
|
|
96
|
+
{ text: DELEGATED_SDD_TASK },
|
|
97
|
+
ctx({ cwd, hasUI: false, sessionManager: { getSessionId: () => "sdd-preflight-natural-parent" } }),
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(result, { action: "continue" });
|
|
100
|
+
assert.equal(existsSync(sddPreflightDiskPath(cwd)), false, "text alone must not persist preflight");
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(cwd, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("an interactive parent still resolves preflight for an explicit slash SDD command", async () => {
|
|
107
|
+
const input = inputHook();
|
|
108
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-parent-slash-input-"));
|
|
101
109
|
const agentHome = await mkdtemp(join(tmpdir(), "gentle-pi-parent-agent-home-"));
|
|
102
110
|
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
103
111
|
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
104
112
|
try {
|
|
105
113
|
const result = await input(
|
|
106
|
-
{ text:
|
|
107
|
-
ctx({
|
|
108
|
-
cwd,
|
|
109
|
-
hasUI: false,
|
|
110
|
-
sessionManager: { getSessionId: () => "sdd-preflight-interactive-parent" },
|
|
111
|
-
}),
|
|
114
|
+
{ text: "/sdd-new feature" },
|
|
115
|
+
ctx({ cwd, hasUI: false, sessionManager: { getSessionId: () => "sdd-preflight-slash-parent" } }),
|
|
112
116
|
);
|
|
113
117
|
assert.deepEqual(result, { action: "continue" });
|
|
114
|
-
assert.equal(
|
|
115
|
-
existsSync(sddPreflightDiskPath(cwd)),
|
|
116
|
-
true,
|
|
117
|
-
"the parent remains the only actor that resolves and persists preflight",
|
|
118
|
-
);
|
|
118
|
+
assert.equal(existsSync(sddPreflightDiskPath(cwd)), true, "slash SDD commands still resolve preflight");
|
|
119
119
|
} finally {
|
|
120
120
|
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
121
121
|
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
@@ -507,20 +507,25 @@ test("only a structurally valid parent-rendered preflight block can reach an SDD
|
|
|
507
507
|
assert.equal(isParentConfirmedSddPreflightContext("## SDD Session Preflight\ncaller-authored defaults"), false);
|
|
508
508
|
});
|
|
509
509
|
|
|
510
|
-
test("
|
|
510
|
+
test("natural-language SDD text never triggers input preflight", () => {
|
|
511
|
+
// Natural-language intent belongs to the parent/orchestrator. The input hook
|
|
512
|
+
// is syntax-only; execution gates enforce preflight when an SDD action is
|
|
513
|
+
// actually attempted.
|
|
511
514
|
for (const text of [
|
|
512
515
|
"quiero hacer un proyecto con SDD",
|
|
513
516
|
"I want to build this with SDD",
|
|
514
517
|
"por favor usemos SDD para este cambio",
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
for (const text of [
|
|
518
|
+
"do SDD for this change",
|
|
519
|
+
"haz SDD para este cambio",
|
|
520
|
+
"continue the SDD change",
|
|
519
521
|
"Should we use SDD?",
|
|
520
522
|
"no quiero usar SDD por ahora",
|
|
521
|
-
"no necesito usar SDD",
|
|
522
|
-
"I don't want to use SDD",
|
|
523
523
|
"I use SDD sometimes",
|
|
524
|
+
"necesito reportar un bug del preflight de SDD",
|
|
525
|
+
"I need to report a bug in the SDD preflight",
|
|
526
|
+
"quiero comparar SDD con ODD",
|
|
527
|
+
"I want to review the SDD proposal",
|
|
528
|
+
"necesito una explicación de SDD",
|
|
524
529
|
]) {
|
|
525
530
|
assert.equal(isSddPreflightTrigger(text), false, text);
|
|
526
531
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
3
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -102,7 +102,7 @@ async function captureOwnedRoutes(t: TestContext): Promise<{ captures: Capture[]
|
|
|
102
102
|
const cp = childProcess();
|
|
103
103
|
const original = { execFileSync: cp.execFileSync, execFile: cp.execFile, spawnSync: cp.spawnSync };
|
|
104
104
|
const captures: Capture[] = [];
|
|
105
|
-
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-windows-hidden-workspace-"));
|
|
105
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-windows-hidden-workspace-")));
|
|
106
106
|
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
107
107
|
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
108
108
|
const fakeSync = ((command: string, args: readonly string[], options: Options = {}) => {
|