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.
Files changed (35) hide show
  1. package/assets/orchestrator-delegation.md +6 -4
  2. package/assets/orchestrator-memory.md +1 -1
  3. package/assets/sdd-orchestrator-workflow.md +1 -1
  4. package/docs/gentle-shell.md +1 -1
  5. package/docs/readme-reference.md +43 -18
  6. package/extensions/gentle-agents.ts +22 -1
  7. package/extensions/gentle-ai.ts +33 -138
  8. package/extensions/pi-pretty.ts +13 -1
  9. package/lib/background-subagents-policy.ts +148 -0
  10. package/lib/native-review-cli.ts +9 -0
  11. package/lib/review-candidate-view.ts +27 -6
  12. package/lib/sdd-preflight.ts +5 -19
  13. package/package.json +1 -1
  14. package/runtime/native-review-cli.mjs +9 -0
  15. package/scripts/gentle-ai-installer.mjs +10 -10
  16. package/scripts/verify-package-files.mjs +2 -2
  17. package/skills/chained-pr/SKILL.md +2 -1
  18. package/skills/work-unit-commits/SKILL.md +9 -0
  19. package/tests/background-subagents-default-mode.test.ts +105 -0
  20. package/tests/gentle-agents.test.ts +14 -1
  21. package/tests/gentle-ai-binary.test.ts +1 -1
  22. package/tests/gentle-ai-installer.test.ts +47 -47
  23. package/tests/gentle-theme.test.ts +2 -0
  24. package/tests/native-review-capability-contract.test.ts +14 -1
  25. package/tests/odd-routing-contract.test.ts +49 -2
  26. package/tests/package-manifest.test.ts +6 -6
  27. package/tests/pi-pretty.test.ts +5 -0
  28. package/tests/review-base-ref-hint.test.ts +39 -0
  29. package/tests/review-candidate-view.test.ts +55 -0
  30. package/tests/runtime-harness.mjs +12 -10
  31. package/tests/sdd-managed-runtime-settlement.test.ts +2 -2
  32. package/tests/sdd-preflight-rpc-input.test.ts +29 -29
  33. package/tests/sdd-preflight.test.ts +12 -7
  34. package/tests/windows-hidden-processes.test.ts +2 -2
  35. 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(ctx.ui.selections.length, 1, "natural SDD triggers reuse confirmed session choices");
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
- // gentle-pi#1001: the natural-language SDD `input` hook called the parent-only
12
- // preflight resolver for every matching prompt, RPC children included.
13
- // `ensureSddPreflight` rejects RPC by design, the hook caught that rejection and
14
- // answered `handled`, and Pi's `AgentSession.prompt()` accepts handled input and
15
- // returns before `before_agent_start` and the model loop. A delegated child
16
- // therefore ACKed a prompt it never ran: no agent_start, zero turns, zero tool
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("the delegated task text reaches the preflight interceptor", () => {
56
- // Guards the premise of the tests below: if the classifier stops matching
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 matching prompt is not consumed by the preflight interceptor", async () => {
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 still resolves preflight for a matching prompt", async () => {
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: DELEGATED_SDD_TASK },
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("affirmative natural-language SDD requests trigger preflight without matching a finite phrase list", () => {
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
- assert.equal(isSddPreflightTrigger(text), true, text);
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 = {}) => {
@@ -40,7 +40,7 @@
40
40
  "muted": "muted",
41
41
  "dim": "dim",
42
42
  "text": "text",
43
- "thinkingText": "muted",
43
+ "thinkingText": "softRose",
44
44
  "selectedBg": "selection",
45
45
  "scrollbarThumb": "border",
46
46
  "searchMatchBg": "deepPink",