gentle-pi 0.4.5 → 0.5.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.md
CHANGED
|
@@ -27,7 +27,7 @@ Keep synthesis short by default: decision, outcome, next action. Expand only whe
|
|
|
27
27
|
|
|
28
28
|
## Language Boundary
|
|
29
29
|
|
|
30
|
-
User-facing conversation should stay in the user's language and follow the currently
|
|
30
|
+
User-facing conversation should stay in the user's language and follow the currently active persona mode. The active mode is stated in the `Current persona mode:` line in the identity/harness section of this system prompt — always honor it for language style.
|
|
31
31
|
|
|
32
32
|
Subagent-facing prompts should be written in English by default, even when the user speaks Spanish. Translate the user's request into concise English before delegation. This keeps token usage lower and gives built-in/project subagents a consistent operating language without changing the user-facing persona.
|
|
33
33
|
|
|
@@ -271,6 +271,15 @@ When Engram or another callable memory package is available, the parent owns mem
|
|
|
271
271
|
- SDD artifact keys: in memory/hybrid mode, phase artifacts should use stable topic keys such as `sdd/<change>/proposal`, `sdd/<change>/spec`, `sdd/<change>/design`, `sdd/<change>/tasks`, `sdd/<change>/apply-progress`, and `sdd/<change>/verify-report`.
|
|
272
272
|
- If memory tools are unavailable, do not pretend persistence exists; return artifacts inline and/or write OpenSpec files.
|
|
273
273
|
|
|
274
|
+
Memory lifecycle rule (when Engram exposes lifecycle metadata/tooling):
|
|
275
|
+
|
|
276
|
+
- At session start or before architecture-sensitive work, call `mem_review` with action `list` for the current project when the tool is available.
|
|
277
|
+
- If `mem_review` is unavailable, do not fail the task. Continue with normal `mem_context`/`mem_search`, and still apply lifecycle metadata from any returned observations when present.
|
|
278
|
+
- `active` memories may be used normally.
|
|
279
|
+
- `needs_review` memories are stale context, not trusted facts.
|
|
280
|
+
- When a retrieved memory is marked `needs_review`, surface that stale context to the user and verify it against current evidence before relying on it.
|
|
281
|
+
- Do NOT call `mem_review` with action `mark_reviewed` automatically. Only call `mark_reviewed` after explicit user confirmation or through a dedicated memory maintenance command.
|
|
282
|
+
|
|
274
283
|
## Execution Mode
|
|
275
284
|
|
|
276
285
|
Use the session's SDD preflight choice:
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -149,6 +149,7 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
|
|
|
149
149
|
- Be direct, technical, concise, warm, and professional.
|
|
150
150
|
- Always respond in the same language the user writes in.
|
|
151
151
|
- Do not use slang or regional expressions.
|
|
152
|
+
- When the user writes Spanish, use neutral/professional Spanish. Do NOT use voseo (vos tenés, vos querés, hacé, andá, etc.) or any regional conjugations.
|
|
152
153
|
- Act as a senior architect and teacher: concepts before code, no shortcuts.
|
|
153
154
|
- Treat AI as a tool directed by the human; never present yourself as a default chatbot.
|
|
154
155
|
- Push back when the user asks for code without enough context or understanding.
|
|
@@ -157,7 +158,14 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
|
|
|
157
158
|
function buildGentlePrompt(persona: PersonaMode): string {
|
|
158
159
|
const personaPrompt =
|
|
159
160
|
persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
|
|
161
|
+
const languageBoundary =
|
|
162
|
+
persona === "neutral"
|
|
163
|
+
? "Language: neutral/professional Spanish when the user writes Spanish. Do NOT use voseo or Rioplatense regional expressions."
|
|
164
|
+
: "Language: natural Rioplatense Spanish with voseo when the user writes Spanish.";
|
|
160
165
|
return `## el Gentleman Identity and Harness
|
|
166
|
+
|
|
167
|
+
Current persona mode: ${persona}
|
|
168
|
+
|
|
161
169
|
You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
|
|
162
170
|
|
|
163
171
|
Identity contract:
|
|
@@ -169,6 +177,8 @@ Identity contract:
|
|
|
169
177
|
|
|
170
178
|
${personaPrompt}
|
|
171
179
|
|
|
180
|
+
${languageBoundary}
|
|
181
|
+
|
|
172
182
|
Harness principles:
|
|
173
183
|
- el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
|
|
174
184
|
- Prefer SDD/OpenSpec artifacts over floating chat context for non-trivial work.
|
|
@@ -182,11 +192,19 @@ Harness principles:
|
|
|
182
192
|
${getOrchestratorPrompt()}`;
|
|
183
193
|
}
|
|
184
194
|
|
|
195
|
+
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
196
|
+
// between `git` and the subcommand. Short flags may be followed by a separate value token.
|
|
197
|
+
const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)* `;
|
|
198
|
+
const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b`);
|
|
199
|
+
|
|
185
200
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
186
|
-
|
|
201
|
+
// Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or .
|
|
202
|
+
/\brm\s+-rf\s+(?:\/(?:\s|$)|~(?:\/|\s|$)|[$]HOME(?:\/|\s|$)|\.\.?(?:\s|$))/,
|
|
187
203
|
/\bgit\s+reset\s+--hard\b/,
|
|
188
204
|
/\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/,
|
|
189
|
-
|
|
205
|
+
// Force-push deny: tolerates git global flags (e.g. -C /repo) before the subcommand
|
|
206
|
+
new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s--force(?:-with-lease)?\b)`),
|
|
207
|
+
new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s-[^\s-]*f)`),
|
|
190
208
|
/\bchmod\s+-R\s+777\b/,
|
|
191
209
|
/\bchown\s+-R\b/,
|
|
192
210
|
];
|
|
@@ -194,11 +212,189 @@ const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
|
194
212
|
const CONFIRM_BASH_PATTERNS: RegExp[] = [
|
|
195
213
|
/\bgit\s+push\b/,
|
|
196
214
|
/\bgit\s+rebase\b/,
|
|
197
|
-
/\bgit\s+branch\s
|
|
215
|
+
/\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
|
|
198
216
|
/\bnpm\s+publish\b/,
|
|
199
217
|
/\bpi\s+remove\b/,
|
|
200
218
|
];
|
|
201
219
|
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Autonomous guard — runtime guardrails config
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const GUARD_ACTION = {
|
|
225
|
+
ALLOW: "allow",
|
|
226
|
+
CONFIRM: "confirm",
|
|
227
|
+
BLOCK: "block",
|
|
228
|
+
} as const;
|
|
229
|
+
|
|
230
|
+
type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION];
|
|
231
|
+
type GuardClassification = GuardAction | "not-guarded";
|
|
232
|
+
|
|
233
|
+
const GUARDED_COMMAND_KEY = {
|
|
234
|
+
GIT_PUSH: "gitPush",
|
|
235
|
+
GIT_REBASE: "gitRebase",
|
|
236
|
+
GIT_BRANCH_DELETE_FORCE: "gitBranchDeleteForce",
|
|
237
|
+
NPM_PUBLISH: "npmPublish",
|
|
238
|
+
PI_REMOVE: "piRemove",
|
|
239
|
+
} as const;
|
|
240
|
+
|
|
241
|
+
type GuardedCommandKey = (typeof GUARDED_COMMAND_KEY)[keyof typeof GUARDED_COMMAND_KEY];
|
|
242
|
+
|
|
243
|
+
type GuardedCommandsConfig = Partial<Record<GuardedCommandKey, GuardAction>>;
|
|
244
|
+
|
|
245
|
+
interface RuntimeGuardrailsConfig {
|
|
246
|
+
autonomousMode: boolean;
|
|
247
|
+
guardedCommands: GuardedCommandsConfig;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface LoadGuardrailsOptions {
|
|
251
|
+
/** Override the config home directory (used in tests to avoid touching ~/.pi). */
|
|
252
|
+
gentlePiConfigHome?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
|
|
256
|
+
gitPush: GIT_PUSH_RE,
|
|
257
|
+
gitRebase: /\bgit\s+rebase\b/,
|
|
258
|
+
gitBranchDeleteForce: /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
|
|
259
|
+
npmPublish: /\bnpm\s+publish\b/,
|
|
260
|
+
piRemove: /\bpi\s+remove\b/,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const AUTONOMOUS_DEFAULT_ACTIONS: Record<GuardedCommandKey, GuardAction> = {
|
|
264
|
+
gitPush: "allow",
|
|
265
|
+
gitRebase: "confirm",
|
|
266
|
+
gitBranchDeleteForce: "confirm",
|
|
267
|
+
npmPublish: "block",
|
|
268
|
+
piRemove: "confirm",
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = {
|
|
272
|
+
autonomousMode: false,
|
|
273
|
+
guardedCommands: {},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Classify a shell command under the runtime guard policy.
|
|
278
|
+
*
|
|
279
|
+
* Ordering (non-negotiable):
|
|
280
|
+
* 1. Hard-deny patterns → "block" (always, cannot be overridden by config)
|
|
281
|
+
* 2. If autonomousMode is false → mirror the legacy CONFIRM_BASH_PATTERNS result
|
|
282
|
+
* 3. If autonomousMode is true → use configured GuardAction for the matched key
|
|
283
|
+
* (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands)
|
|
284
|
+
* 4. No match → "not-guarded"
|
|
285
|
+
*/
|
|
286
|
+
function classifyGuardedCommand(
|
|
287
|
+
command: string,
|
|
288
|
+
config: RuntimeGuardrailsConfig,
|
|
289
|
+
): GuardClassification {
|
|
290
|
+
// Step 1: hard-deny always wins, regardless of any config
|
|
291
|
+
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
292
|
+
if (pattern.test(command)) return "block";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Step 2 & 3: find which guarded key (if any) this command matches
|
|
296
|
+
for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [GuardedCommandKey, RegExp][]) {
|
|
297
|
+
if (!pattern.test(command)) continue;
|
|
298
|
+
|
|
299
|
+
// Matched a guarded key
|
|
300
|
+
if (!config.autonomousMode) {
|
|
301
|
+
// Legacy behavior: any match → confirm
|
|
302
|
+
return "confirm";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Autonomous mode: use configured action, fall back to sensible defaults
|
|
306
|
+
const configuredAction = config.guardedCommands[key];
|
|
307
|
+
return configuredAction ?? AUTONOMOUS_DEFAULT_ACTIONS[key];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return "not-guarded";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseGuardrailsConfigFile(
|
|
314
|
+
raw: string,
|
|
315
|
+
): RuntimeGuardrailsConfig | undefined {
|
|
316
|
+
let parsed: unknown;
|
|
317
|
+
try {
|
|
318
|
+
parsed = JSON.parse(raw);
|
|
319
|
+
} catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
if (!isRecord(parsed)) return undefined;
|
|
323
|
+
|
|
324
|
+
const autonomousMode = parsed.autonomousMode === true;
|
|
325
|
+
|
|
326
|
+
const rawCommands = isRecord(parsed.guardedCommands) ? parsed.guardedCommands : {};
|
|
327
|
+
const guardedCommands: GuardedCommandsConfig = {};
|
|
328
|
+
const validActions = new Set<string>(["allow", "confirm", "block"]);
|
|
329
|
+
for (const [key, value] of Object.entries(rawCommands)) {
|
|
330
|
+
if (
|
|
331
|
+
typeof value === "string" &&
|
|
332
|
+
validActions.has(value) &&
|
|
333
|
+
Object.values(GUARDED_COMMAND_KEY).includes(key as GuardedCommandKey)
|
|
334
|
+
) {
|
|
335
|
+
guardedCommands[key as GuardedCommandKey] = value as GuardAction;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { autonomousMode, guardedCommands };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Load the runtime guardrails config.
|
|
344
|
+
*
|
|
345
|
+
* Resolution order (project overrides global):
|
|
346
|
+
* 1. Check GENTLE_PI_AUTONOMOUS_MODE env var — if "1", forces autonomousMode=true
|
|
347
|
+
* and uses default guarded command actions.
|
|
348
|
+
* 2. Read global config from ${gentlePiConfigHome}/runtime-guardrails.json
|
|
349
|
+
* 3. Read project config from ${cwd}/.pi/gentle-ai/runtime-guardrails.json
|
|
350
|
+
* (project values are merged on top of global)
|
|
351
|
+
* 4. Any parse/read error anywhere → fail safe (return SAFE_GUARDRAILS_CONFIG)
|
|
352
|
+
*/
|
|
353
|
+
function loadRuntimeGuardrailsConfig(
|
|
354
|
+
cwd: string,
|
|
355
|
+
options: LoadGuardrailsOptions = {},
|
|
356
|
+
): RuntimeGuardrailsConfig {
|
|
357
|
+
try {
|
|
358
|
+
// Env var override: forces autonomous mode with default actions
|
|
359
|
+
if (process.env.GENTLE_PI_AUTONOMOUS_MODE === "1") {
|
|
360
|
+
return { autonomousMode: true, guardedCommands: {} };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome();
|
|
364
|
+
const globalConfigPath = join(configHome, "runtime-guardrails.json");
|
|
365
|
+
const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json");
|
|
366
|
+
|
|
367
|
+
let merged: RuntimeGuardrailsConfig = { autonomousMode: false, guardedCommands: {} };
|
|
368
|
+
|
|
369
|
+
if (existsSync(globalConfigPath)) {
|
|
370
|
+
const globalParsed = parseGuardrailsConfigFile(
|
|
371
|
+
readFileSync(globalConfigPath, "utf8"),
|
|
372
|
+
);
|
|
373
|
+
if (!globalParsed) return SAFE_GUARDRAILS_CONFIG;
|
|
374
|
+
merged = globalParsed;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (existsSync(projectConfigPath)) {
|
|
378
|
+
const projectParsed = parseGuardrailsConfigFile(
|
|
379
|
+
readFileSync(projectConfigPath, "utf8"),
|
|
380
|
+
);
|
|
381
|
+
if (!projectParsed) return SAFE_GUARDRAILS_CONFIG;
|
|
382
|
+
// Project values fully override global values
|
|
383
|
+
merged = {
|
|
384
|
+
autonomousMode: projectParsed.autonomousMode,
|
|
385
|
+
guardedCommands: {
|
|
386
|
+
...merged.guardedCommands,
|
|
387
|
+
...projectParsed.guardedCommands,
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return merged;
|
|
393
|
+
} catch {
|
|
394
|
+
return SAFE_GUARDRAILS_CONFIG;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
202
398
|
const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]);
|
|
203
399
|
const PATH_INPUT_KEYS = new Set([
|
|
204
400
|
"path",
|
|
@@ -329,25 +525,6 @@ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined {
|
|
|
329
525
|
return undefined;
|
|
330
526
|
}
|
|
331
527
|
|
|
332
|
-
function evaluateDeniedCommand(
|
|
333
|
-
command: string,
|
|
334
|
-
): ToolCallEventResult | undefined {
|
|
335
|
-
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
336
|
-
if (pattern.test(command)) {
|
|
337
|
-
return {
|
|
338
|
-
block: true,
|
|
339
|
-
reason:
|
|
340
|
-
"Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.",
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return undefined;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function commandRequiresConfirmation(command: string): boolean {
|
|
348
|
-
return CONFIRM_BASH_PATTERNS.some((pattern) => pattern.test(command));
|
|
349
|
-
}
|
|
350
|
-
|
|
351
528
|
function normalizePolicyPath(value: string): string {
|
|
352
529
|
return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase();
|
|
353
530
|
}
|
|
@@ -408,9 +585,23 @@ async function confirmCommand(
|
|
|
408
585
|
command: string,
|
|
409
586
|
ctx: ExtensionContext,
|
|
410
587
|
): Promise<ToolCallEventResult | undefined> {
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
588
|
+
const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
|
|
589
|
+
const classification = classifyGuardedCommand(command, guardrailsConfig);
|
|
590
|
+
|
|
591
|
+
if (classification === "block") {
|
|
592
|
+
return {
|
|
593
|
+
block: true,
|
|
594
|
+
reason:
|
|
595
|
+
"Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.",
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (classification === "not-guarded") return undefined;
|
|
600
|
+
|
|
601
|
+
// classification is "allow" or "confirm" from this point on
|
|
602
|
+
if (classification === "allow") return undefined;
|
|
603
|
+
|
|
604
|
+
// classification === "confirm"
|
|
414
605
|
if (!ctx.hasUI) {
|
|
415
606
|
return {
|
|
416
607
|
block: true,
|
|
@@ -1573,6 +1764,9 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
1573
1764
|
export const __testing = {
|
|
1574
1765
|
listAgentsFromDir,
|
|
1575
1766
|
listAgentsFromDirAsync,
|
|
1767
|
+
classifyGuardedCommand,
|
|
1768
|
+
loadRuntimeGuardrailsConfig,
|
|
1769
|
+
buildGentlePrompt,
|
|
1576
1770
|
};
|
|
1577
1771
|
|
|
1578
1772
|
export default function gentleAi(pi: ExtensionAPI): void {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -84,6 +84,33 @@ test("rendered SDD preflight prompt is English artifact copy", () => {
|
|
|
84
84
|
}
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test("orchestrator Memory Contract carries the Engram memory lifecycle rule", async () => {
|
|
88
|
+
const orchestrator = await readFile(join(ROOT, "assets/orchestrator.md"), "utf8");
|
|
89
|
+
|
|
90
|
+
// Mirrors gentle-ai's engram-protocol/engram-convention lifecycle rule (PRs #842 + #844),
|
|
91
|
+
// in its final availability-gated form: agents must treat needs_review memories as stale,
|
|
92
|
+
// prefer mem_review when present, fall back safely when it is not, and never auto-mark reviewed.
|
|
93
|
+
for (const required of [
|
|
94
|
+
"when Engram exposes lifecycle metadata/tooling",
|
|
95
|
+
"At session start or before architecture-sensitive work",
|
|
96
|
+
"call `mem_review` with action `list`",
|
|
97
|
+
"for the current project when the tool is available",
|
|
98
|
+
"If `mem_review` is unavailable, do not fail the task",
|
|
99
|
+
"Continue with normal `mem_context`/`mem_search`",
|
|
100
|
+
"still apply lifecycle metadata from any returned observations when present",
|
|
101
|
+
"`active` memories may be used normally",
|
|
102
|
+
"`needs_review` memories are stale context, not trusted facts",
|
|
103
|
+
"verify it against current evidence before relying on it",
|
|
104
|
+
"Do NOT call `mem_review` with action `mark_reviewed` automatically",
|
|
105
|
+
"Only call `mark_reviewed` after explicit user confirmation or through a dedicated memory maintenance command",
|
|
106
|
+
]) {
|
|
107
|
+
assert.ok(
|
|
108
|
+
orchestrator.includes(required),
|
|
109
|
+
`orchestrator.md missing memory lifecycle rule: ${required}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
87
114
|
test("SDD proposal questions focus on business and PRD gaps", async () => {
|
|
88
115
|
const proposalAgent = await readFile(join(ROOT, "assets/agents/sdd-proposal.md"), "utf8");
|
|
89
116
|
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
7
|
+
|
|
8
|
+
const { classifyGuardedCommand } = __testing;
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function makeTmpDir(): string {
|
|
15
|
+
return mkdtempSync(join(tmpdir(), "gentle-pi-autonomous-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeConfig(dir: string, relPath: string, content: unknown): void {
|
|
19
|
+
const full = join(dir, relPath);
|
|
20
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
21
|
+
writeFileSync(full, JSON.stringify(content, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// classifyGuardedCommand — base contract
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
test("classifyGuardedCommand: git push plain → confirm by default (no autonomous mode)", () => {
|
|
29
|
+
const result = classifyGuardedCommand("git push origin main", {
|
|
30
|
+
autonomousMode: false,
|
|
31
|
+
guardedCommands: {},
|
|
32
|
+
});
|
|
33
|
+
assert.equal(result, "confirm");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("classifyGuardedCommand: git rebase → confirm by default (no autonomous mode)", () => {
|
|
37
|
+
const result = classifyGuardedCommand("git rebase main", {
|
|
38
|
+
autonomousMode: false,
|
|
39
|
+
guardedCommands: {},
|
|
40
|
+
});
|
|
41
|
+
assert.equal(result, "confirm");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("classifyGuardedCommand: npm publish → confirm by default (no autonomous mode)", () => {
|
|
45
|
+
const result = classifyGuardedCommand("npm publish", {
|
|
46
|
+
autonomousMode: false,
|
|
47
|
+
guardedCommands: {},
|
|
48
|
+
});
|
|
49
|
+
assert.equal(result, "confirm");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("classifyGuardedCommand: unknown command → not-guarded", () => {
|
|
53
|
+
const result = classifyGuardedCommand("echo hello", {
|
|
54
|
+
autonomousMode: false,
|
|
55
|
+
guardedCommands: {},
|
|
56
|
+
});
|
|
57
|
+
assert.equal(result, "not-guarded");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Hard-deny always blocks regardless of autonomous mode or config
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
test("classifyGuardedCommand: git push --force always blocked even with gitPush=allow", () => {
|
|
65
|
+
const result = classifyGuardedCommand("git push --force origin main", {
|
|
66
|
+
autonomousMode: true,
|
|
67
|
+
guardedCommands: { gitPush: "allow" },
|
|
68
|
+
});
|
|
69
|
+
assert.equal(result, "block");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("classifyGuardedCommand: git push --force-with-lease always blocked", () => {
|
|
73
|
+
const result = classifyGuardedCommand("git push --force-with-lease origin main", {
|
|
74
|
+
autonomousMode: true,
|
|
75
|
+
guardedCommands: { gitPush: "allow" },
|
|
76
|
+
});
|
|
77
|
+
assert.equal(result, "block");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("classifyGuardedCommand: git push -f always blocked even in autonomous mode", () => {
|
|
81
|
+
const result = classifyGuardedCommand("git push -f origin main", {
|
|
82
|
+
autonomousMode: true,
|
|
83
|
+
guardedCommands: { gitPush: "allow" },
|
|
84
|
+
});
|
|
85
|
+
assert.equal(result, "block");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("classifyGuardedCommand: git reset --hard always blocked", () => {
|
|
89
|
+
const result = classifyGuardedCommand("git reset --hard HEAD~1", {
|
|
90
|
+
autonomousMode: true,
|
|
91
|
+
guardedCommands: {},
|
|
92
|
+
});
|
|
93
|
+
assert.equal(result, "block");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("classifyGuardedCommand: rm -rf / always blocked", () => {
|
|
97
|
+
const result = classifyGuardedCommand("rm -rf /", {
|
|
98
|
+
autonomousMode: true,
|
|
99
|
+
guardedCommands: {},
|
|
100
|
+
});
|
|
101
|
+
assert.equal(result, "block");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("classifyGuardedCommand: rm -rf ~ always blocked", () => {
|
|
105
|
+
const result = classifyGuardedCommand("rm -rf ~", {
|
|
106
|
+
autonomousMode: true,
|
|
107
|
+
guardedCommands: {},
|
|
108
|
+
});
|
|
109
|
+
assert.equal(result, "block");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("classifyGuardedCommand: chmod -R 777 always blocked", () => {
|
|
113
|
+
const result = classifyGuardedCommand("chmod -R 777 /etc", {
|
|
114
|
+
autonomousMode: true,
|
|
115
|
+
guardedCommands: {},
|
|
116
|
+
});
|
|
117
|
+
assert.equal(result, "block");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Autonomous mode + allow action
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
test("classifyGuardedCommand: git push plain allowed when autonomousMode=true and gitPush=allow", () => {
|
|
125
|
+
const result = classifyGuardedCommand("git push origin feature/test", {
|
|
126
|
+
autonomousMode: true,
|
|
127
|
+
guardedCommands: { gitPush: "allow" },
|
|
128
|
+
});
|
|
129
|
+
assert.equal(result, "allow");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("classifyGuardedCommand: git push plain still confirm when autonomousMode=false even with gitPush=allow in config", () => {
|
|
133
|
+
const result = classifyGuardedCommand("git push origin feature/test", {
|
|
134
|
+
autonomousMode: false,
|
|
135
|
+
guardedCommands: { gitPush: "allow" },
|
|
136
|
+
});
|
|
137
|
+
assert.equal(result, "confirm");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Autonomous mode + confirm action (stays gated)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
test("classifyGuardedCommand: git rebase stays confirm when autonomousMode=true and gitRebase=confirm", () => {
|
|
145
|
+
const result = classifyGuardedCommand("git rebase main", {
|
|
146
|
+
autonomousMode: true,
|
|
147
|
+
guardedCommands: { gitRebase: "confirm" },
|
|
148
|
+
});
|
|
149
|
+
assert.equal(result, "confirm");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("classifyGuardedCommand: git branch -D stays confirm in autonomous mode (gitBranchDeleteForce=confirm)", () => {
|
|
153
|
+
const result = classifyGuardedCommand("git branch -D old-feature", {
|
|
154
|
+
autonomousMode: true,
|
|
155
|
+
guardedCommands: { gitBranchDeleteForce: "confirm" },
|
|
156
|
+
});
|
|
157
|
+
assert.equal(result, "confirm");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("classifyGuardedCommand: git branch -df stays confirm in autonomous mode", () => {
|
|
161
|
+
const result = classifyGuardedCommand("git branch -df old-feature", {
|
|
162
|
+
autonomousMode: true,
|
|
163
|
+
guardedCommands: { gitBranchDeleteForce: "confirm" },
|
|
164
|
+
});
|
|
165
|
+
assert.equal(result, "confirm");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("classifyGuardedCommand: git branch --delete --force stays confirm in autonomous mode", () => {
|
|
169
|
+
const result = classifyGuardedCommand("git branch --delete --force old-feature", {
|
|
170
|
+
autonomousMode: true,
|
|
171
|
+
guardedCommands: { gitBranchDeleteForce: "confirm" },
|
|
172
|
+
});
|
|
173
|
+
assert.equal(result, "confirm");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Autonomous mode + block action
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
test("classifyGuardedCommand: npm publish blocked when autonomousMode=true and npmPublish=block", () => {
|
|
181
|
+
const result = classifyGuardedCommand("npm publish", {
|
|
182
|
+
autonomousMode: true,
|
|
183
|
+
guardedCommands: { npmPublish: "block" },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(result, "block");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// loadRuntimeGuardrailsConfig — file loading
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
test("loadRuntimeGuardrailsConfig: returns off config when no file exists", () => {
|
|
193
|
+
const dir = makeTmpDir();
|
|
194
|
+
try {
|
|
195
|
+
const config = __testing.loadRuntimeGuardrailsConfig(dir, {
|
|
196
|
+
gentlePiConfigHome: join(dir, "global-config"),
|
|
197
|
+
});
|
|
198
|
+
assert.equal(config.autonomousMode, false);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("loadRuntimeGuardrailsConfig: env var GENTLE_PI_AUTONOMOUS_MODE=1 activates mode", () => {
|
|
205
|
+
const original = process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
206
|
+
process.env.GENTLE_PI_AUTONOMOUS_MODE = "1";
|
|
207
|
+
const dir = makeTmpDir();
|
|
208
|
+
try {
|
|
209
|
+
const config = __testing.loadRuntimeGuardrailsConfig(dir, {
|
|
210
|
+
gentlePiConfigHome: join(dir, "global-config"),
|
|
211
|
+
});
|
|
212
|
+
assert.equal(config.autonomousMode, true);
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
if (original === undefined) delete process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
216
|
+
else process.env.GENTLE_PI_AUTONOMOUS_MODE = original;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("loadRuntimeGuardrailsConfig: global config file activates autonomous mode", () => {
|
|
221
|
+
const dir = makeTmpDir();
|
|
222
|
+
try {
|
|
223
|
+
const globalConfigDir = join(dir, "global-config");
|
|
224
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
225
|
+
autonomousMode: true,
|
|
226
|
+
guardedCommands: { gitPush: "allow" },
|
|
227
|
+
});
|
|
228
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
229
|
+
gentlePiConfigHome: globalConfigDir,
|
|
230
|
+
});
|
|
231
|
+
assert.equal(config.autonomousMode, true);
|
|
232
|
+
assert.equal(config.guardedCommands.gitPush, "allow");
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(dir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("loadRuntimeGuardrailsConfig: project config overrides global config", () => {
|
|
239
|
+
const dir = makeTmpDir();
|
|
240
|
+
try {
|
|
241
|
+
const globalConfigDir = join(dir, "global-config");
|
|
242
|
+
const projectDir = join(dir, "project");
|
|
243
|
+
|
|
244
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
245
|
+
autonomousMode: true,
|
|
246
|
+
guardedCommands: { gitPush: "allow", npmPublish: "confirm" },
|
|
247
|
+
});
|
|
248
|
+
writeConfig(projectDir, join(".pi", "gentle-ai", "runtime-guardrails.json"), {
|
|
249
|
+
autonomousMode: true,
|
|
250
|
+
guardedCommands: { gitPush: "confirm", npmPublish: "block" },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const config = __testing.loadRuntimeGuardrailsConfig(projectDir, {
|
|
254
|
+
gentlePiConfigHome: globalConfigDir,
|
|
255
|
+
});
|
|
256
|
+
assert.equal(config.autonomousMode, true);
|
|
257
|
+
assert.equal(config.guardedCommands.gitPush, "confirm");
|
|
258
|
+
assert.equal(config.guardedCommands.npmPublish, "block");
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("loadRuntimeGuardrailsConfig: invalid JSON in config fails safe (autonomousMode=false)", () => {
|
|
265
|
+
const dir = makeTmpDir();
|
|
266
|
+
try {
|
|
267
|
+
const globalConfigDir = join(dir, "global-config");
|
|
268
|
+
const configPath = join(globalConfigDir, "runtime-guardrails.json");
|
|
269
|
+
mkdirSync(globalConfigDir, { recursive: true });
|
|
270
|
+
writeFileSync(configPath, "{ not valid json }");
|
|
271
|
+
|
|
272
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
273
|
+
gentlePiConfigHome: globalConfigDir,
|
|
274
|
+
});
|
|
275
|
+
assert.equal(config.autonomousMode, false);
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(dir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("loadRuntimeGuardrailsConfig: non-object JSON fails safe", () => {
|
|
282
|
+
const dir = makeTmpDir();
|
|
283
|
+
try {
|
|
284
|
+
const globalConfigDir = join(dir, "global-config");
|
|
285
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", [1, 2, 3]);
|
|
286
|
+
|
|
287
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
288
|
+
gentlePiConfigHome: globalConfigDir,
|
|
289
|
+
});
|
|
290
|
+
assert.equal(config.autonomousMode, false);
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(dir, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("loadRuntimeGuardrailsConfig: invalid project config fails safe (autonomousMode=false)", () => {
|
|
297
|
+
const dir = makeTmpDir();
|
|
298
|
+
try {
|
|
299
|
+
const globalConfigDir = join(dir, "global-config");
|
|
300
|
+
const projectDir = join(dir, "project");
|
|
301
|
+
|
|
302
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
303
|
+
autonomousMode: true,
|
|
304
|
+
guardedCommands: { gitPush: "allow" },
|
|
305
|
+
});
|
|
306
|
+
const projectConfigPath = join(
|
|
307
|
+
projectDir,
|
|
308
|
+
".pi",
|
|
309
|
+
"gentle-ai",
|
|
310
|
+
"runtime-guardrails.json",
|
|
311
|
+
);
|
|
312
|
+
mkdirSync(dirname(projectConfigPath), { recursive: true });
|
|
313
|
+
writeFileSync(projectConfigPath, "{ bad json }");
|
|
314
|
+
|
|
315
|
+
const config = __testing.loadRuntimeGuardrailsConfig(projectDir, {
|
|
316
|
+
gentlePiConfigHome: globalConfigDir,
|
|
317
|
+
});
|
|
318
|
+
assert.equal(config.autonomousMode, false);
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(dir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// When autonomous mode is OFF nothing changes vs current behavior
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
test("classifyGuardedCommand: pi remove confirm when autonomousMode=false", () => {
|
|
329
|
+
const result = classifyGuardedCommand("pi remove my-package", {
|
|
330
|
+
autonomousMode: false,
|
|
331
|
+
guardedCommands: { piRemove: "allow" },
|
|
332
|
+
});
|
|
333
|
+
assert.equal(result, "confirm");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("classifyGuardedCommand: pi remove allowed when autonomousMode=true and piRemove=allow", () => {
|
|
337
|
+
const result = classifyGuardedCommand("pi remove my-package", {
|
|
338
|
+
autonomousMode: true,
|
|
339
|
+
guardedCommands: { piRemove: "allow" },
|
|
340
|
+
});
|
|
341
|
+
assert.equal(result, "allow");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Fix 1: git global flags bypass — git -C <dir> push / git --work-tree push
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
test("classifyGuardedCommand: git -C /repo push --force → block even with gitPush=allow", () => {
|
|
349
|
+
const result = classifyGuardedCommand("git -C /repo push --force origin main", {
|
|
350
|
+
autonomousMode: true,
|
|
351
|
+
guardedCommands: { gitPush: "allow" },
|
|
352
|
+
});
|
|
353
|
+
assert.equal(result, "block");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("classifyGuardedCommand: git --work-tree=/tmp push --force → block", () => {
|
|
357
|
+
const result = classifyGuardedCommand("git --work-tree=/tmp push --force origin main", {
|
|
358
|
+
autonomousMode: true,
|
|
359
|
+
guardedCommands: { gitPush: "allow" },
|
|
360
|
+
});
|
|
361
|
+
assert.equal(result, "block");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("classifyGuardedCommand: git -C /repo push -f → block", () => {
|
|
365
|
+
const result = classifyGuardedCommand("git -C /repo push -f origin main", {
|
|
366
|
+
autonomousMode: true,
|
|
367
|
+
guardedCommands: { gitPush: "allow" },
|
|
368
|
+
});
|
|
369
|
+
assert.equal(result, "block");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("classifyGuardedCommand: git -C /repo push origin feat → classified as gitPush (allow when configured)", () => {
|
|
373
|
+
const result = classifyGuardedCommand("git -C /repo push origin feat", {
|
|
374
|
+
autonomousMode: true,
|
|
375
|
+
guardedCommands: { gitPush: "allow" },
|
|
376
|
+
});
|
|
377
|
+
assert.equal(result, "allow");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("classifyGuardedCommand: git -C /repo push origin feat → confirm when autonomousMode=false", () => {
|
|
381
|
+
const result = classifyGuardedCommand("git -C /repo push origin feat", {
|
|
382
|
+
autonomousMode: false,
|
|
383
|
+
guardedCommands: {},
|
|
384
|
+
});
|
|
385
|
+
assert.equal(result, "confirm");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Fix 2: rm -rf $HOME was not blocked (dead regex branch)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
test("classifyGuardedCommand: rm -rf $HOME → block", () => {
|
|
393
|
+
const result = classifyGuardedCommand("rm -rf $HOME", {
|
|
394
|
+
autonomousMode: true,
|
|
395
|
+
guardedCommands: {},
|
|
396
|
+
});
|
|
397
|
+
assert.equal(result, "block");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("classifyGuardedCommand: rm -rf $HOME/foo → block", () => {
|
|
401
|
+
const result = classifyGuardedCommand("rm -rf $HOME/foo", {
|
|
402
|
+
autonomousMode: true,
|
|
403
|
+
guardedCommands: {},
|
|
404
|
+
});
|
|
405
|
+
assert.equal(result, "block");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Fix 5a: gitBranchDeleteForce allow path is tested
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
test("classifyGuardedCommand: gitBranchDeleteForce=allow in autonomous mode → allow", () => {
|
|
413
|
+
const result = classifyGuardedCommand("git branch -D old-feature", {
|
|
414
|
+
autonomousMode: true,
|
|
415
|
+
guardedCommands: { gitBranchDeleteForce: "allow" },
|
|
416
|
+
});
|
|
417
|
+
assert.equal(result, "allow");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Fix 5b: AUTONOMOUS_DEFAULT_ACTIONS fallback — empty guardedCommands in autonomous mode
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
test("classifyGuardedCommand: autonomousMode=true, empty guardedCommands, gitPush defaults to allow", () => {
|
|
425
|
+
const result = classifyGuardedCommand("git push origin main", {
|
|
426
|
+
autonomousMode: true,
|
|
427
|
+
guardedCommands: {},
|
|
428
|
+
});
|
|
429
|
+
assert.equal(result, "allow");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Fix 5c: env var negatives — only "1" activates autonomous mode
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
test("loadRuntimeGuardrailsConfig: GENTLE_PI_AUTONOMOUS_MODE=0 does NOT activate autonomous mode", () => {
|
|
437
|
+
const original = process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
438
|
+
process.env.GENTLE_PI_AUTONOMOUS_MODE = "0";
|
|
439
|
+
const dir = makeTmpDir();
|
|
440
|
+
try {
|
|
441
|
+
const config = __testing.loadRuntimeGuardrailsConfig(dir, {
|
|
442
|
+
gentlePiConfigHome: join(dir, "global-config"),
|
|
443
|
+
});
|
|
444
|
+
assert.equal(config.autonomousMode, false);
|
|
445
|
+
} finally {
|
|
446
|
+
rmSync(dir, { recursive: true, force: true });
|
|
447
|
+
if (original === undefined) delete process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
448
|
+
else process.env.GENTLE_PI_AUTONOMOUS_MODE = original;
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("loadRuntimeGuardrailsConfig: GENTLE_PI_AUTONOMOUS_MODE=true does NOT activate autonomous mode", () => {
|
|
453
|
+
const original = process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
454
|
+
process.env.GENTLE_PI_AUTONOMOUS_MODE = "true";
|
|
455
|
+
const dir = makeTmpDir();
|
|
456
|
+
try {
|
|
457
|
+
const config = __testing.loadRuntimeGuardrailsConfig(dir, {
|
|
458
|
+
gentlePiConfigHome: join(dir, "global-config"),
|
|
459
|
+
});
|
|
460
|
+
assert.equal(config.autonomousMode, false);
|
|
461
|
+
} finally {
|
|
462
|
+
rmSync(dir, { recursive: true, force: true });
|
|
463
|
+
if (original === undefined) delete process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
464
|
+
else process.env.GENTLE_PI_AUTONOMOUS_MODE = original;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("loadRuntimeGuardrailsConfig: GENTLE_PI_AUTONOMOUS_MODE='' does NOT activate autonomous mode", () => {
|
|
469
|
+
const original = process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
470
|
+
process.env.GENTLE_PI_AUTONOMOUS_MODE = "";
|
|
471
|
+
const dir = makeTmpDir();
|
|
472
|
+
try {
|
|
473
|
+
const config = __testing.loadRuntimeGuardrailsConfig(dir, {
|
|
474
|
+
gentlePiConfigHome: join(dir, "global-config"),
|
|
475
|
+
});
|
|
476
|
+
assert.equal(config.autonomousMode, false);
|
|
477
|
+
} finally {
|
|
478
|
+
rmSync(dir, { recursive: true, force: true });
|
|
479
|
+
if (original === undefined) delete process.env.GENTLE_PI_AUTONOMOUS_MODE;
|
|
480
|
+
else process.env.GENTLE_PI_AUTONOMOUS_MODE = original;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// Fix 5d: JSON config autonomousMode strict === true check
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
test("loadRuntimeGuardrailsConfig: autonomousMode:1 (number) in JSON does NOT activate autonomous mode", () => {
|
|
489
|
+
const dir = makeTmpDir();
|
|
490
|
+
try {
|
|
491
|
+
const globalConfigDir = join(dir, "global-config");
|
|
492
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
493
|
+
autonomousMode: 1,
|
|
494
|
+
guardedCommands: {},
|
|
495
|
+
});
|
|
496
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
497
|
+
gentlePiConfigHome: globalConfigDir,
|
|
498
|
+
});
|
|
499
|
+
assert.equal(config.autonomousMode, false);
|
|
500
|
+
} finally {
|
|
501
|
+
rmSync(dir, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('loadRuntimeGuardrailsConfig: autonomousMode:"true" (string) in JSON does NOT activate autonomous mode', () => {
|
|
506
|
+
const dir = makeTmpDir();
|
|
507
|
+
try {
|
|
508
|
+
const globalConfigDir = join(dir, "global-config");
|
|
509
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
510
|
+
autonomousMode: "true",
|
|
511
|
+
guardedCommands: {},
|
|
512
|
+
});
|
|
513
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
514
|
+
gentlePiConfigHome: globalConfigDir,
|
|
515
|
+
});
|
|
516
|
+
assert.equal(config.autonomousMode, false);
|
|
517
|
+
} finally {
|
|
518
|
+
rmSync(dir, { recursive: true, force: true });
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT activate autonomous mode", () => {
|
|
523
|
+
const dir = makeTmpDir();
|
|
524
|
+
try {
|
|
525
|
+
const globalConfigDir = join(dir, "global-config");
|
|
526
|
+
writeConfig(globalConfigDir, "runtime-guardrails.json", {
|
|
527
|
+
autonomousMode: {},
|
|
528
|
+
guardedCommands: {},
|
|
529
|
+
});
|
|
530
|
+
const config = __testing.loadRuntimeGuardrailsConfig(join(dir, "project"), {
|
|
531
|
+
gentlePiConfigHome: globalConfigDir,
|
|
532
|
+
});
|
|
533
|
+
assert.equal(config.autonomousMode, false);
|
|
534
|
+
} finally {
|
|
535
|
+
rmSync(dir, { recursive: true, force: true });
|
|
536
|
+
}
|
|
537
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
4
|
+
|
|
5
|
+
// These tests assert that the composed main-agent prompt (built by buildGentlePrompt)
|
|
6
|
+
// does not encourage Rioplatense voseo in neutral mode, and does include the expected
|
|
7
|
+
// voseo/Rioplatense markers in gentleman mode.
|
|
8
|
+
|
|
9
|
+
test("neutral mode composed prompt does not instruct to use voseo", () => {
|
|
10
|
+
const prompt = __testing.buildGentlePrompt("neutral");
|
|
11
|
+
// The neutral prompt must never tell the model to USE voseo
|
|
12
|
+
assert.doesNotMatch(
|
|
13
|
+
prompt,
|
|
14
|
+
/answer in natural Rioplatense Spanish with voseo/i,
|
|
15
|
+
"neutral prompt must not instruct to use Rioplatense voseo",
|
|
16
|
+
);
|
|
17
|
+
assert.doesNotMatch(
|
|
18
|
+
prompt,
|
|
19
|
+
/uses natural Rioplatense voseo/i,
|
|
20
|
+
"neutral prompt must not describe voseo as the language mode to use",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("neutral mode composed prompt has no positive voseo/Rioplatense instruction and includes explicit prohibition", () => {
|
|
25
|
+
const prompt = __testing.buildGentlePrompt("neutral");
|
|
26
|
+
// Any sentence that affirmatively tells the model to use voseo or natural Rioplatense
|
|
27
|
+
// must be absent. The prohibition line ("Do NOT use voseo") is the only allowed voseo
|
|
28
|
+
// mention. We match patterns that indicate a positive directive by requiring the word
|
|
29
|
+
// "use" (or its inflections) in proximity to "voseo" or "Rioplatense" WITHOUT a
|
|
30
|
+
// preceding negation — concretely, lines that start/contain "use", "uses", or "with voseo".
|
|
31
|
+
assert.doesNotMatch(
|
|
32
|
+
prompt,
|
|
33
|
+
/\bwith voseo\b/i,
|
|
34
|
+
"neutral prompt must not contain 'with voseo' (positive directive)",
|
|
35
|
+
);
|
|
36
|
+
assert.doesNotMatch(
|
|
37
|
+
prompt,
|
|
38
|
+
/\buse(?:s)? (?:natural )?Rioplatense\b/i,
|
|
39
|
+
"neutral prompt must not instruct to use Rioplatense",
|
|
40
|
+
);
|
|
41
|
+
// The prohibition line must remain present so the model knows NOT to use voseo
|
|
42
|
+
assert.match(
|
|
43
|
+
prompt,
|
|
44
|
+
/Do NOT use voseo/i,
|
|
45
|
+
"neutral prompt must explicitly prohibit voseo",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("gentleman mode composed prompt contains voseo reference", () => {
|
|
50
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
51
|
+
assert.match(
|
|
52
|
+
prompt,
|
|
53
|
+
/voseo/i,
|
|
54
|
+
"gentleman prompt must reference voseo",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("gentleman mode composed prompt contains Rioplatense reference", () => {
|
|
59
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
60
|
+
assert.match(
|
|
61
|
+
prompt,
|
|
62
|
+
/Rioplatense/i,
|
|
63
|
+
"gentleman prompt must reference Rioplatense",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("neutral mode composed prompt explicitly states active mode is neutral", () => {
|
|
68
|
+
const prompt = __testing.buildGentlePrompt("neutral");
|
|
69
|
+
assert.match(
|
|
70
|
+
prompt,
|
|
71
|
+
/Current persona mode: neutral/i,
|
|
72
|
+
"neutral prompt must state active mode is neutral",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("gentleman mode composed prompt explicitly states active mode is gentleman", () => {
|
|
77
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
78
|
+
assert.match(
|
|
79
|
+
prompt,
|
|
80
|
+
/Current persona mode: gentleman/i,
|
|
81
|
+
"gentleman prompt must state active mode is gentleman",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("neutral mode composed prompt explicitly forbids voseo conjugations", () => {
|
|
86
|
+
const prompt = __testing.buildGentlePrompt("neutral");
|
|
87
|
+
// The neutral persona prompt must explicitly forbid voseo conjugation forms
|
|
88
|
+
assert.match(
|
|
89
|
+
prompt,
|
|
90
|
+
/Do NOT use voseo/i,
|
|
91
|
+
"neutral prompt must explicitly forbid voseo",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("neutral and gentleman modes produce different language-boundary text", () => {
|
|
96
|
+
const neutralPrompt = __testing.buildGentlePrompt("neutral");
|
|
97
|
+
const gentlemanPrompt = __testing.buildGentlePrompt("gentleman");
|
|
98
|
+
|
|
99
|
+
// The language-boundary section must differ between modes
|
|
100
|
+
assert.notEqual(
|
|
101
|
+
neutralPrompt,
|
|
102
|
+
gentlemanPrompt,
|
|
103
|
+
"neutral and gentleman prompts must differ",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Neutral must not include a positive instruction to use Rioplatense
|
|
107
|
+
assert.doesNotMatch(
|
|
108
|
+
neutralPrompt,
|
|
109
|
+
/Language: natural Rioplatense/i,
|
|
110
|
+
"neutral prompt must not contain positive 'natural Rioplatense' language instruction",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Gentleman must contain the Rioplatense instruction
|
|
114
|
+
assert.match(
|
|
115
|
+
gentlemanPrompt,
|
|
116
|
+
/Language: natural Rioplatense/i,
|
|
117
|
+
"gentleman prompt must contain 'Language: natural Rioplatense' instruction",
|
|
118
|
+
);
|
|
119
|
+
});
|