gentle-pi 2.1.2 → 2.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.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,771 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import test, { after, type TestContext } from "node:test";
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Background subagents policy (issue #256).
21
+ //
22
+ // The policy loader mirrors loadRuntimeGuardrailsConfig: project file >
23
+ // global file > env var > default off, strict schema decode, fail-closed to
24
+ // "off" on any malformed input.
25
+ //
26
+ // Capability answers one question: is `subagent_run` callable? The live pi
27
+ // tool registry answers it directly and wins when it carries any signal. With
28
+ // no registry handle (prompt rendering outside a session, or a runtime without
29
+ // getActiveTools) capability falls back to the installed pi-subagents package.
30
+ // That fallback probes the package root's own package.json, NOT an `agents/`
31
+ // subdirectory: pi-subagents-j0k3r v1.5.2 ships index.ts, src/, skills/ and
32
+ // scripts/ and no agents/ at all, so the old agents-dir probe reported
33
+ // "absent" on every real install and left the background policy inert.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const {
37
+ loadBackgroundSubagentsPolicy,
38
+ resolveBackgroundSubagentsPolicy,
39
+ parseBackgroundSubagentsPolicyFile,
40
+ resolveBackgroundSubagentsCapability,
41
+ readActiveToolNames,
42
+ renderBackgroundSubagentsStatusLine,
43
+ renderOrchestratorPrompt,
44
+ getOrchestratorPrompt,
45
+ } = __testing;
46
+
47
+ const scratchRoots: string[] = [];
48
+
49
+ function makeScratch(prefix: string): string {
50
+ const dir = mkdtempSync(join(tmpdir(), prefix));
51
+ scratchRoots.push(dir);
52
+ return dir;
53
+ }
54
+
55
+ after(() => {
56
+ for (const dir of scratchRoots) rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ function writePolicyFile(dir: string, policy: string): void {
60
+ mkdirSync(dir, { recursive: true });
61
+ writeFileSync(
62
+ join(dir, "background-subagents.json"),
63
+ JSON.stringify({ schema: "gentle-pi.background-subagents/v1", policy }),
64
+ );
65
+ }
66
+
67
+ const EMPTY_ENV = {} as Record<string, string | undefined>;
68
+
69
+ /**
70
+ * Materialize an installed subagents package under the cwd-relative candidate
71
+ * root. `agentsDir` reproduces the hypothetical legacy layout; omitting it
72
+ * reproduces the real published layout, which ships no agents/ directory.
73
+ */
74
+ function installSubagentsPackage(
75
+ cwd: string,
76
+ packageName: string,
77
+ options: { agentsDir?: boolean } = {},
78
+ ): void {
79
+ const root = join(cwd, ".pi", "npm", "node_modules", packageName);
80
+ mkdirSync(root, { recursive: true });
81
+ writeFileSync(
82
+ join(root, "package.json"),
83
+ JSON.stringify({ name: packageName, version: "1.5.2", type: "module" }),
84
+ );
85
+ // The real package ships these; none of them is an agents/ directory.
86
+ for (const shipped of ["src", "skills", "scripts"]) {
87
+ mkdirSync(join(root, shipped), { recursive: true });
88
+ }
89
+ if (options.agentsDir) mkdirSync(join(root, "agents"), { recursive: true });
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Strict decode
94
+ // ---------------------------------------------------------------------------
95
+
96
+ test("strict decode accepts exactly the v1 schema with policy on|off", () => {
97
+ assert.equal(
98
+ parseBackgroundSubagentsPolicyFile(
99
+ '{"schema":"gentle-pi.background-subagents/v1","policy":"on"}',
100
+ ),
101
+ "on",
102
+ );
103
+ assert.equal(
104
+ parseBackgroundSubagentsPolicyFile(
105
+ '{"schema":"gentle-pi.background-subagents/v1","policy":"off"}',
106
+ ),
107
+ "off",
108
+ );
109
+ });
110
+
111
+ test("strict decode rejects malformed shapes", () => {
112
+ for (const raw of [
113
+ "not json",
114
+ "[]",
115
+ "null",
116
+ '{"policy":"on"}',
117
+ '{"schema":"gentle-pi.background-subagents/v2","policy":"on"}',
118
+ '{"schema":"gentle-pi.background-subagents/v1","policy":"ON"}',
119
+ '{"schema":"gentle-pi.background-subagents/v1","policy":true}',
120
+ '{"schema":"gentle-pi.background-subagents/v1","policy":"on","extra":1}',
121
+ ]) {
122
+ assert.equal(
123
+ parseBackgroundSubagentsPolicyFile(raw),
124
+ undefined,
125
+ `must reject: ${raw}`,
126
+ );
127
+ }
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Cascade: project > global > env > default off
132
+ // ---------------------------------------------------------------------------
133
+
134
+ test("default is off with no file and no env", () => {
135
+ const cwd = makeScratch("gp-bg-none-");
136
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
137
+ assert.equal(
138
+ loadBackgroundSubagentsPolicy(cwd, { gentlePiConfigHome: configHome, env: EMPTY_ENV }),
139
+ "off",
140
+ );
141
+ });
142
+
143
+ test("project file overrides global file and env", () => {
144
+ const cwd = makeScratch("gp-bg-proj-");
145
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
146
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "on");
147
+ writePolicyFile(configHome, "off");
148
+ assert.equal(
149
+ loadBackgroundSubagentsPolicy(cwd, {
150
+ gentlePiConfigHome: configHome,
151
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "off" },
152
+ }),
153
+ "on",
154
+ );
155
+ });
156
+
157
+ test("global file overrides env when no project file exists", () => {
158
+ const cwd = makeScratch("gp-bg-glob-");
159
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
160
+ writePolicyFile(configHome, "on");
161
+ assert.equal(
162
+ loadBackgroundSubagentsPolicy(cwd, {
163
+ gentlePiConfigHome: configHome,
164
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "off" },
165
+ }),
166
+ "on",
167
+ );
168
+ });
169
+
170
+ test("env var applies only when no policy file exists, and only exact on|off", () => {
171
+ const cwd = makeScratch("gp-bg-env-");
172
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
173
+ assert.equal(
174
+ loadBackgroundSubagentsPolicy(cwd, {
175
+ gentlePiConfigHome: configHome,
176
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" },
177
+ }),
178
+ "on",
179
+ );
180
+ for (const invalid of ["1", "true", "ON", "yes", ""]) {
181
+ assert.equal(
182
+ loadBackgroundSubagentsPolicy(cwd, {
183
+ gentlePiConfigHome: configHome,
184
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: invalid },
185
+ }),
186
+ "off",
187
+ `env value "${invalid}" must fail closed to off`,
188
+ );
189
+ }
190
+ });
191
+
192
+ test("a malformed higher-priority file fails closed to off instead of falling through", () => {
193
+ const cwd = makeScratch("gp-bg-mal-");
194
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
195
+ const projectDir = join(cwd, ".pi", "gentle-ai");
196
+ mkdirSync(projectDir, { recursive: true });
197
+ writeFileSync(join(projectDir, "background-subagents.json"), "{malformed");
198
+ writePolicyFile(configHome, "on");
199
+ assert.equal(
200
+ loadBackgroundSubagentsPolicy(cwd, {
201
+ gentlePiConfigHome: configHome,
202
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" },
203
+ }),
204
+ "off",
205
+ );
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Capability degrade
210
+ // ---------------------------------------------------------------------------
211
+
212
+ test("capability is absent when no subagents package exists anywhere", () => {
213
+ const cwd = makeScratch("gp-bg-cap-absent-");
214
+ assert.equal(resolveBackgroundSubagentsCapability(cwd), "absent");
215
+ });
216
+
217
+ // Regression: the exact shape of a real install. pi-subagents-j0k3r v1.5.2
218
+ // ships no agents/ directory, so the previous agents-dir probe reported
219
+ // "absent" while subagent_run was in fact installed and callable.
220
+ test("capability is ready when the subagents package is installed without an agents directory", () => {
221
+ const cwd = makeScratch("gp-bg-cap-real-");
222
+ installSubagentsPackage(cwd, "pi-subagents-j0k3r");
223
+ assert.equal(resolveBackgroundSubagentsCapability(cwd), "ready");
224
+ });
225
+
226
+ test("capability is ready for either supported package name", () => {
227
+ for (const packageName of ["pi-subagents-j0k3r", "pi-subagents"]) {
228
+ const cwd = makeScratch("gp-bg-cap-name-");
229
+ installSubagentsPackage(cwd, packageName);
230
+ assert.equal(
231
+ resolveBackgroundSubagentsCapability(cwd),
232
+ "ready",
233
+ `package name ${packageName} must be detected`,
234
+ );
235
+ }
236
+ });
237
+
238
+ test("capability stays ready for a layout that does ship an agents directory", () => {
239
+ const cwd = makeScratch("gp-bg-cap-legacy-");
240
+ installSubagentsPackage(cwd, "pi-subagents", { agentsDir: true });
241
+ assert.equal(resolveBackgroundSubagentsCapability(cwd), "ready");
242
+ });
243
+
244
+ test("a bare directory with no package.json is not an installed package", () => {
245
+ const cwd = makeScratch("gp-bg-cap-bare-");
246
+ mkdirSync(join(cwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"), {
247
+ recursive: true,
248
+ });
249
+ assert.equal(resolveBackgroundSubagentsCapability(cwd), "absent");
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Live tool registry outranks the filesystem fallback
254
+ // ---------------------------------------------------------------------------
255
+
256
+ test("a live tool registry listing subagent_run reports ready with no package on disk", () => {
257
+ const cwd = makeScratch("gp-bg-cap-tools-ready-");
258
+ assert.equal(
259
+ resolveBackgroundSubagentsCapability(cwd, ["read", "bash", "subagent_run"]),
260
+ "ready",
261
+ );
262
+ assert.equal(
263
+ resolveBackgroundSubagentsCapability(cwd, ["pi-subagents-j0k3r.subagent_run"]),
264
+ "ready",
265
+ "a namespaced tool name must still count",
266
+ );
267
+ });
268
+
269
+ test("a live tool registry without subagent_run outranks an installed package", () => {
270
+ const cwd = makeScratch("gp-bg-cap-tools-absent-");
271
+ installSubagentsPackage(cwd, "pi-subagents-j0k3r");
272
+ assert.equal(resolveBackgroundSubagentsCapability(cwd, ["read", "bash"]), "absent");
273
+ });
274
+
275
+ test("an empty tool list carries no signal and falls back to the package probe", () => {
276
+ const cwd = makeScratch("gp-bg-cap-tools-empty-");
277
+ installSubagentsPackage(cwd, "pi-subagents-j0k3r");
278
+ assert.equal(resolveBackgroundSubagentsCapability(cwd, []), "ready");
279
+ });
280
+
281
+ test("readActiveToolNames reads the pi registry and degrades to undefined", () => {
282
+ assert.deepEqual(
283
+ readActiveToolNames({ getActiveTools: () => ["read", "subagent_run"] }),
284
+ ["read", "subagent_run"],
285
+ );
286
+ assert.deepEqual(
287
+ readActiveToolNames({ getActiveTools: () => [{ name: "subagent_run" }, 7, ""] }),
288
+ ["subagent_run"],
289
+ "non-string entries are normalized and blanks dropped",
290
+ );
291
+ assert.equal(readActiveToolNames({}), undefined, "no handle means no signal");
292
+ assert.equal(
293
+ readActiveToolNames({ getActiveTools: () => "nope" }),
294
+ undefined,
295
+ "a non-array result means no signal",
296
+ );
297
+ assert.equal(
298
+ readActiveToolNames({
299
+ getActiveTools: () => {
300
+ throw new Error("registry unavailable");
301
+ },
302
+ }),
303
+ undefined,
304
+ "a throwing registry means no signal",
305
+ );
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Token rendering
310
+ // ---------------------------------------------------------------------------
311
+
312
+ test("status line renders policy and capability", () => {
313
+ assert.equal(
314
+ renderBackgroundSubagentsStatusLine({ policy: "on", capability: "ready" }),
315
+ "Background subagent policy: on (capability: ready)",
316
+ );
317
+ assert.equal(
318
+ renderBackgroundSubagentsStatusLine({ policy: "off", capability: "absent" }),
319
+ "Background subagent policy: off (capability: absent)",
320
+ );
321
+ });
322
+
323
+ test("renderOrchestratorPrompt substitutes the background policy token", () => {
324
+ const assetsDir = join(process.cwd(), "assets");
325
+ const rendered = renderOrchestratorPrompt(assetsDir, {
326
+ policy: "on",
327
+ capability: "ready",
328
+ });
329
+ assert.match(rendered, /Background subagent policy: on \(capability: ready\)/);
330
+ assert.doesNotMatch(rendered, /\{\{GENTLE_PI_BACKGROUND_POLICY\}\}/);
331
+ });
332
+
333
+ test("renderOrchestratorPrompt defaults to the fail-closed off/absent rendering", () => {
334
+ const assetsDir = join(process.cwd(), "assets");
335
+ const rendered = renderOrchestratorPrompt(assetsDir);
336
+ assert.match(rendered, /Background subagent policy: off \(capability: absent\)/);
337
+ });
338
+
339
+ // The project policy file pins the policy half of the status line so these
340
+ // assertions never depend on the developer's ambient global config.
341
+ test("the rendered status line flips to ready when the subagents package is installed", () => {
342
+ const cwd = makeScratch("gp-bg-cap-render-");
343
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "on");
344
+ assert.match(
345
+ getOrchestratorPrompt(cwd),
346
+ /Background subagent policy: on \(capability: absent\)/,
347
+ "no package installed yet",
348
+ );
349
+ installSubagentsPackage(cwd, "pi-subagents-j0k3r");
350
+ assert.match(
351
+ getOrchestratorPrompt(cwd),
352
+ /Background subagent policy: on \(capability: ready\)/,
353
+ );
354
+ });
355
+
356
+ test("the rendered status line reports ready from a live registry alone", () => {
357
+ const cwd = makeScratch("gp-bg-cap-render-tools-");
358
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "on");
359
+ assert.match(
360
+ getOrchestratorPrompt(cwd, ["read", "subagent_run"]),
361
+ /Background subagent policy: on \(capability: ready\)/,
362
+ );
363
+ });
364
+
365
+ test("getOrchestratorPrompt renders exactly one background status line", () => {
366
+ const rendered = getOrchestratorPrompt();
367
+ const matches = rendered.match(/Background subagent policy: (?:on|off) \(capability: (?:ready|absent)\)/g) ?? [];
368
+ assert.equal(matches.length, 1, "the always-on core must carry exactly one status line");
369
+ });
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Source attribution (issue #345)
373
+ //
374
+ // Four sources can decide the policy and the first hit wins, so a user who
375
+ // edits the global file while a project file exists sees no effect. The
376
+ // resolver reports WHICH source decided so the command can say so; the plain
377
+ // loader delegates to it, which is the only way the two can never disagree.
378
+ // ---------------------------------------------------------------------------
379
+
380
+ test("the resolver attributes the project file, with its path", () => {
381
+ const cwd = makeScratch("gp-bg-src-project-");
382
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
383
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "on");
384
+ writePolicyFile(configHome, "off");
385
+ const resolution = resolveBackgroundSubagentsPolicy(cwd, {
386
+ gentlePiConfigHome: configHome,
387
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "off" },
388
+ });
389
+ assert.equal(resolution.policy, "on");
390
+ assert.equal(resolution.source, "project_file");
391
+ assert.equal(
392
+ resolution.projectFile,
393
+ join(cwd, ".pi", "gentle-ai", "background-subagents.json"),
394
+ );
395
+ assert.equal(resolution.globalFile, join(configHome, "background-subagents.json"));
396
+ assert.equal(resolution.projectFileExists, true);
397
+ assert.equal(resolution.globalFileExists, true, "the shadowed global file is still reported");
398
+ assert.equal(resolution.malformed, false);
399
+ assert.equal(resolution.envValue, "off");
400
+ });
401
+
402
+ test("the resolver attributes the global file when no project file exists", () => {
403
+ const cwd = makeScratch("gp-bg-src-global-");
404
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
405
+ writePolicyFile(configHome, "on");
406
+ const resolution = resolveBackgroundSubagentsPolicy(cwd, {
407
+ gentlePiConfigHome: configHome,
408
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "off" },
409
+ });
410
+ assert.equal(resolution.policy, "on");
411
+ assert.equal(resolution.source, "global_file");
412
+ assert.equal(resolution.projectFileExists, false);
413
+ assert.equal(resolution.globalFileExists, true);
414
+ });
415
+
416
+ test("the resolver attributes the environment variable when no file exists", () => {
417
+ const cwd = makeScratch("gp-bg-src-env-");
418
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
419
+ const resolution = resolveBackgroundSubagentsPolicy(cwd, {
420
+ gentlePiConfigHome: configHome,
421
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" },
422
+ });
423
+ assert.equal(resolution.policy, "on");
424
+ assert.equal(resolution.source, "environment");
425
+ assert.equal(resolution.envValue, "on");
426
+ });
427
+
428
+ test("the resolver attributes the built-in default when nothing else decides", () => {
429
+ const cwd = makeScratch("gp-bg-src-default-");
430
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
431
+ const resolution = resolveBackgroundSubagentsPolicy(cwd, {
432
+ gentlePiConfigHome: configHome,
433
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "yes" },
434
+ });
435
+ assert.equal(resolution.policy, "off");
436
+ assert.equal(resolution.source, "default");
437
+ assert.equal(
438
+ resolution.envValue,
439
+ "yes",
440
+ "an unrecognized env value is reported verbatim so the command can call it inert",
441
+ );
442
+ });
443
+
444
+ test("the resolver attributes a malformed file to that file and does not fall through", () => {
445
+ const cwd = makeScratch("gp-bg-src-malformed-");
446
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
447
+ const projectDir = join(cwd, ".pi", "gentle-ai");
448
+ mkdirSync(projectDir, { recursive: true });
449
+ writeFileSync(join(projectDir, "background-subagents.json"), "{malformed");
450
+ writePolicyFile(configHome, "on");
451
+ const resolution = resolveBackgroundSubagentsPolicy(cwd, {
452
+ gentlePiConfigHome: configHome,
453
+ env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" },
454
+ });
455
+ assert.equal(resolution.policy, "off", "a malformed file fails closed");
456
+ assert.equal(
457
+ resolution.source,
458
+ "project_file",
459
+ "the malformed file still decided; it did not fall through to the global file",
460
+ );
461
+ assert.equal(resolution.malformed, true);
462
+ });
463
+
464
+ test("loadBackgroundSubagentsPolicy delegates to the resolver so the two can never disagree", () => {
465
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
466
+ writePolicyFile(configHome, "on");
467
+ const scenarios: Array<{ cwd: string; env: Record<string, string | undefined> }> = [];
468
+ const bare = makeScratch("gp-bg-agree-bare-");
469
+ scenarios.push({ cwd: bare, env: {} });
470
+ scenarios.push({ cwd: bare, env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } });
471
+ const projectOn = makeScratch("gp-bg-agree-project-");
472
+ writePolicyFile(join(projectOn, ".pi", "gentle-ai"), "off");
473
+ scenarios.push({ cwd: projectOn, env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } });
474
+ const malformed = makeScratch("gp-bg-agree-malformed-");
475
+ mkdirSync(join(malformed, ".pi", "gentle-ai"), { recursive: true });
476
+ writeFileSync(
477
+ join(malformed, ".pi", "gentle-ai", "background-subagents.json"),
478
+ "{malformed",
479
+ );
480
+ scenarios.push({ cwd: malformed, env: { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } });
481
+ for (const scenario of scenarios) {
482
+ const options = { gentlePiConfigHome: configHome, env: scenario.env };
483
+ assert.equal(
484
+ loadBackgroundSubagentsPolicy(scenario.cwd, options),
485
+ resolveBackgroundSubagentsPolicy(scenario.cwd, options).policy,
486
+ `loader and resolver must agree for ${scenario.cwd}`,
487
+ );
488
+ }
489
+ });
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // /gentle:background-subagents command (issue #345)
493
+ //
494
+ // The policy had no user-facing surface at all: it could only be set by
495
+ // hand-writing JSON or exporting an env var, and the deciding source was
496
+ // visible to nobody. The command mirrors /gentle:review-mode — status|enable|
497
+ // disable, user-initiated only, Pi automation never toggles it.
498
+ // ---------------------------------------------------------------------------
499
+
500
+ interface CommandFixture {
501
+ description?: string;
502
+ handler: (args: string, ctx: ExtensionContext) => Promise<void>;
503
+ }
504
+
505
+ function registeredCommands(activeTools?: readonly string[]): Map<string, CommandFixture> {
506
+ const commands = new Map<string, CommandFixture>();
507
+ createGentleAiExtension({ nativeReviewCli: null })({
508
+ on() {},
509
+ registerTool() {},
510
+ registerCommand(name: string, definition: CommandFixture) {
511
+ commands.set(name, definition);
512
+ },
513
+ ...(activeTools === undefined ? {} : { getActiveTools: () => activeTools }),
514
+ } as unknown as ExtensionAPI);
515
+ return commands;
516
+ }
517
+
518
+ function notifyContext(
519
+ cwd: string,
520
+ notices: Array<{ message: string; type?: string }>,
521
+ ): ExtensionContext {
522
+ return {
523
+ cwd,
524
+ hasUI: false,
525
+ ui: {
526
+ notify: (message: string, type?: string) => {
527
+ notices.push({ message, type });
528
+ },
529
+ },
530
+ } as unknown as ExtensionContext;
531
+ }
532
+
533
+ /** Point the command's global config home at a scratch dir, never at ~/.pi. */
534
+ function scopedEnv(t: TestContext, values: Record<string, string | undefined>): void {
535
+ const previous = new Map<string, string | undefined>();
536
+ for (const [key, value] of Object.entries(values)) {
537
+ previous.set(key, process.env[key]);
538
+ if (value === undefined) delete process.env[key];
539
+ else process.env[key] = value;
540
+ }
541
+ t.after(() => {
542
+ for (const [key, value] of previous) {
543
+ if (value === undefined) delete process.env[key];
544
+ else process.env[key] = value;
545
+ }
546
+ });
547
+ }
548
+
549
+ async function runBackgroundSubagents(
550
+ t: TestContext,
551
+ argument: string,
552
+ cwd: string,
553
+ configHome: string,
554
+ env: Record<string, string | undefined> = {},
555
+ ): Promise<{ message: string; type?: string }> {
556
+ scopedEnv(t, {
557
+ GENTLE_PI_CONFIG_HOME: configHome,
558
+ GENTLE_PI_BACKGROUND_SUBAGENTS: undefined,
559
+ ...env,
560
+ });
561
+ const command = registeredCommands().get("gentle:background-subagents");
562
+ assert.ok(command, "gentle:background-subagents must be registered");
563
+ const notices: Array<{ message: string; type?: string }> = [];
564
+ await command!.handler(argument, notifyContext(cwd, notices));
565
+ assert.equal(notices.length, 1, "one invocation reports exactly once");
566
+ return notices[0]!;
567
+ }
568
+
569
+ test("gentle:background-subagents is registered and declares user-initiated sub-actions", () => {
570
+ const command = registeredCommands().get("gentle:background-subagents");
571
+ assert.ok(command, "gentle:background-subagents must be registered");
572
+ assert.match(command!.description ?? "", /status\|enable\|disable/);
573
+ assert.match(
574
+ command!.description ?? "",
575
+ /user-initiated only; Pi automation never toggles it/,
576
+ );
577
+ });
578
+
579
+ test("no argument reports the effective policy, the deciding default, and the capability", async (t) => {
580
+ const cwd = makeScratch("gp-bg-cmd-default-");
581
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
582
+ const notice = await runBackgroundSubagents(t, "", cwd, configHome);
583
+ assert.equal(notice.type, "info");
584
+ assert.equal(
585
+ notice.message,
586
+ [
587
+ "background subagents: off (decided by built-in default; capability: absent)",
588
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
589
+ ].join("\n"),
590
+ );
591
+ });
592
+
593
+ test("status names the project file that decided and the global file it shadows", async (t) => {
594
+ const cwd = makeScratch("gp-bg-cmd-project-");
595
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
596
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "on");
597
+ writePolicyFile(configHome, "off");
598
+ installSubagentsPackage(cwd, "pi-subagents-j0k3r");
599
+ const notice = await runBackgroundSubagents(t, "status", cwd, configHome);
600
+ assert.equal(notice.type, "info");
601
+ assert.equal(
602
+ notice.message,
603
+ [
604
+ `background subagents: on (decided by project file ${join(cwd, ".pi", "gentle-ai", "background-subagents.json")}; capability: ready)`,
605
+ `The global file ${join(configHome, "background-subagents.json")} exists but is outranked by that project file.`,
606
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
607
+ ].join("\n"),
608
+ );
609
+ });
610
+
611
+ test("status names the global file when it is the deciding source", async (t) => {
612
+ const cwd = makeScratch("gp-bg-cmd-global-");
613
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
614
+ writePolicyFile(configHome, "on");
615
+ const notice = await runBackgroundSubagents(t, "status", cwd, configHome);
616
+ assert.equal(
617
+ notice.message.split("\n")[0],
618
+ `background subagents: on (decided by global file ${join(configHome, "background-subagents.json")}; capability: absent)`,
619
+ );
620
+ });
621
+
622
+ test("status names the environment variable when it is the deciding source", async (t) => {
623
+ const cwd = makeScratch("gp-bg-cmd-env-");
624
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
625
+ const notice = await runBackgroundSubagents(t, "status", cwd, configHome, {
626
+ GENTLE_PI_BACKGROUND_SUBAGENTS: "on",
627
+ });
628
+ assert.equal(notice.type, "info");
629
+ assert.equal(
630
+ notice.message.split("\n")[0],
631
+ "background subagents: on (decided by GENTLE_PI_BACKGROUND_SUBAGENTS; capability: absent)",
632
+ );
633
+ });
634
+
635
+ test("status calls an unrecognized environment value inert instead of silently ignoring it", async (t) => {
636
+ const cwd = makeScratch("gp-bg-cmd-env-bad-");
637
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
638
+ const notice = await runBackgroundSubagents(t, "status", cwd, configHome, {
639
+ GENTLE_PI_BACKGROUND_SUBAGENTS: "true",
640
+ });
641
+ assert.equal(
642
+ notice.message.split("\n")[0],
643
+ "background subagents: off (decided by built-in default; capability: absent)",
644
+ );
645
+ assert.ok(
646
+ notice.message.includes(
647
+ 'GENTLE_PI_BACKGROUND_SUBAGENTS="true" is not a recognized value ("on" or "off"), so it is ignored.',
648
+ ),
649
+ notice.message,
650
+ );
651
+ });
652
+
653
+ test("status reports a malformed deciding file as fail-closed, not as a real off", async (t) => {
654
+ const cwd = makeScratch("gp-bg-cmd-malformed-");
655
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
656
+ const projectFile = join(cwd, ".pi", "gentle-ai", "background-subagents.json");
657
+ mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true });
658
+ writeFileSync(projectFile, "{malformed");
659
+ writePolicyFile(configHome, "on");
660
+ const notice = await runBackgroundSubagents(t, "status", cwd, configHome);
661
+ assert.equal(notice.type, "warning");
662
+ assert.equal(
663
+ notice.message.split("\n")[0],
664
+ `background subagents: off (decided by project file ${projectFile}; capability: absent)`,
665
+ );
666
+ assert.ok(
667
+ notice.message.includes(
668
+ `${projectFile} is present but malformed, so the policy fails closed to off and no lower-priority source is consulted.`,
669
+ ),
670
+ notice.message,
671
+ );
672
+ });
673
+
674
+ test("enable writes the global file and reports that it decides", async (t) => {
675
+ const cwd = makeScratch("gp-bg-cmd-enable-");
676
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
677
+ const globalFile = join(configHome, "background-subagents.json");
678
+ const notice = await runBackgroundSubagents(t, "enable", cwd, configHome);
679
+ assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
680
+ schema: "gentle-pi.background-subagents/v1",
681
+ policy: "on",
682
+ });
683
+ assert.equal(notice.type, "info");
684
+ assert.equal(
685
+ notice.message,
686
+ [
687
+ `background subagents: on (decided by global file ${globalFile}; capability: absent)`,
688
+ `Wrote on to the global file ${globalFile}.`,
689
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
690
+ ].join("\n"),
691
+ );
692
+ });
693
+
694
+ test("disable writes the global file off and reports that it decides", async (t) => {
695
+ const cwd = makeScratch("gp-bg-cmd-disable-");
696
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
697
+ const globalFile = join(configHome, "background-subagents.json");
698
+ writePolicyFile(configHome, "on");
699
+ const notice = await runBackgroundSubagents(t, "disable", cwd, configHome);
700
+ assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
701
+ schema: "gentle-pi.background-subagents/v1",
702
+ policy: "off",
703
+ });
704
+ assert.equal(notice.type, "info");
705
+ assert.equal(
706
+ notice.message.split("\n")[0],
707
+ `background subagents: off (decided by global file ${globalFile}; capability: absent)`,
708
+ );
709
+ });
710
+
711
+ // The defect this command exists to kill: telling a user "enabled" while a
712
+ // project file keeps the policy off. The global write still happens — it is
713
+ // what the user asked for — but the report must lead with the truth.
714
+ test("enable under an outranking project file writes the global file and says it does not take effect", async (t) => {
715
+ const cwd = makeScratch("gp-bg-cmd-outranked-");
716
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
717
+ const projectFile = join(cwd, ".pi", "gentle-ai", "background-subagents.json");
718
+ const globalFile = join(configHome, "background-subagents.json");
719
+ writePolicyFile(join(cwd, ".pi", "gentle-ai"), "off");
720
+ const notice = await runBackgroundSubagents(t, "enable", cwd, configHome);
721
+ assert.deepEqual(
722
+ JSON.parse(readFileSync(globalFile, "utf8")),
723
+ { schema: "gentle-pi.background-subagents/v1", policy: "on" },
724
+ "the requested global write still happens",
725
+ );
726
+ assert.equal(notice.type, "warning");
727
+ assert.equal(
728
+ notice.message,
729
+ [
730
+ `background subagents: off (decided by project file ${projectFile}; capability: absent)`,
731
+ `Wrote on to the global file ${globalFile}.`,
732
+ `That global write does not take effect here: the project file ${projectFile} outranks it. Edit or remove that project file to let the global setting decide.`,
733
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
734
+ ].join("\n"),
735
+ );
736
+ });
737
+
738
+ test("enable with the environment variable set reports where that variable ranks", async (t) => {
739
+ const cwd = makeScratch("gp-bg-cmd-enable-env-");
740
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
741
+ const globalFile = join(configHome, "background-subagents.json");
742
+ const notice = await runBackgroundSubagents(t, "enable", cwd, configHome, {
743
+ GENTLE_PI_BACKGROUND_SUBAGENTS: "off",
744
+ });
745
+ assert.equal(
746
+ notice.message.split("\n")[0],
747
+ `background subagents: on (decided by global file ${globalFile}; capability: absent)`,
748
+ );
749
+ assert.ok(
750
+ notice.message.includes(
751
+ "GENTLE_PI_BACKGROUND_SUBAGENTS=off is set, but both files outrank it and it outranks the built-in default; it decides only when neither file exists.",
752
+ ),
753
+ notice.message,
754
+ );
755
+ });
756
+
757
+ test("an unknown sub-action warns and changes nothing", async (t) => {
758
+ const cwd = makeScratch("gp-bg-cmd-unknown-");
759
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
760
+ const notice = await runBackgroundSubagents(t, "toggle", cwd, configHome);
761
+ assert.equal(notice.type, "warning");
762
+ assert.equal(
763
+ notice.message,
764
+ 'Unknown /gentle:background-subagents sub-action "toggle". Use status, enable, or disable.',
765
+ );
766
+ assert.equal(
767
+ existsSync(join(configHome, "background-subagents.json")),
768
+ false,
769
+ "an unknown sub-action must not write anything",
770
+ );
771
+ });