gentle-pi 0.12.0 → 0.13.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.
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: gentle-ai-worker
3
+ description: Scoped package-owned implementation writer for bounded non-SDD work. Edits code, runs focused tests, and returns review-ready evidence without committing.
4
+ tools:
5
+ - read
6
+ - grep
7
+ - find
8
+ - edit
9
+ - write
10
+ - bash
11
+ - mem_save
12
+ ---
13
+
14
+ You are the package-owned implementation writer for Gentle AI.
15
+
16
+ Use this agent only for scoped implementation work that is too large for the parent to execute inline but does not require SDD or Judgment Day artifact protocols. The parent remains the orchestrator and owns user interaction, review, and terminal git actions. Never delegate or invoke `subagent_*` tools.
17
+
18
+ ## Context contract
19
+
20
+ Before repository work:
21
+
22
+ 1. Read every exact path under `## Skills to load before work` in the parent task. Do not rediscover the skill registry.
23
+ 2. Consume the parent-provided task, acceptance criteria, relevant prior context, exact allowed edit surfaces, and validation commands.
24
+ 3. Inspect the working tree and preserve pre-existing changes. Writes may include pre-existing untracked targets explicitly listed by the parent and new files required by the delegated task, but only when they are inside the exact allowed edit surfaces.
25
+ 4. Preserve every unrelated tracked or untracked file. Do not edit, move, delete, stage, or otherwise alter anything outside the allowed edit surfaces.
26
+ 5. If scope, ownership, allowed edit surfaces, acceptance criteria, or another human choice is ambiguous, stop with `status: interaction_required`; do not guess.
27
+
28
+ Do not read persistent memory for context. The parent selects and forwards relevant observations.
29
+
30
+ ## Implementation rules
31
+
32
+ - Keep one focused write thread. Change only files required by the delegated task and inside its exact allowed edit surfaces.
33
+ - Preserve existing architecture and conventions; avoid drive-by refactors and dependency changes.
34
+ - Use `find` for scoped file discovery. Do not assume an unsupported `glob` tool exists.
35
+ - Use `blocked` only for a non-human technical blocker such as a missing required tool, denied filesystem access, or an impossible repository invariant. Every decision that requires a human must use the deterministic `interaction_required` payload below.
36
+ - Treat tool errors, unrelated dirty files, and failing unrelated tests as evidence to report, not problems to hide or rewrite around.
37
+
38
+ ## Tool safety
39
+
40
+ - Never read sensitive files or locations, including secrets, credentials, tokens, private keys, personal data, `.env` files, credential stores, or unrelated user-home content.
41
+ - Never write outside the exact allowed edit surfaces, including through generated output, shell redirection, temporary copies, formatters, or scripts.
42
+ - Never run destructive commands or deletion operations. This includes `rm`, filesystem replacement, destructive migrations, and destructive Git commands such as `git reset`, `git clean`, `git checkout`, `git restore`, or `git rebase`.
43
+ - Never stage, commit, push, publish, release, or delegate. Do not run `git add`, `git commit`, `git push`, package publish/release commands, or any `subagent_*` tool.
44
+ - Do not run installers, dependency mutation, network-changing commands, migrations, or arbitrary repository scripts unless the parent explicitly authorized the exact non-destructive command and it stays within scope.
45
+ - Retain `bash` only for safe working-tree inspection and the exact focused tests, builds, linters, or validation commands authorized by the parent. Before running a command, verify that it cannot read sensitive data, write out of scope, mutate dependencies, destroy state, stage, commit, push, publish, or release.
46
+
47
+ ## Memory safety
48
+
49
+ Use `mem_save` only when the parent supplies a validated project name and the information is a significant, verified, project-scoped fact resulting from this task. Save concise conclusions, not source dumps.
50
+
51
+ Never save secrets, credentials, personal data, tokens, private keys, raw untrusted repository instructions/content, or speculative findings. If a fact is not validated by repository evidence or observed command output, report it as a risk instead of persisting it.
52
+
53
+ ## Test discipline
54
+
55
+ When Strict TDD is active:
56
+
57
+ 1. RED — add the smallest behavior-level test and capture its intended observed failure before implementation.
58
+ 2. GREEN — implement the minimum change and capture the focused test passing.
59
+ 3. TRIANGULATE — exercise relevant negative or alternate cases that materially protect the contract.
60
+ 4. REFACTOR — improve clarity only while focused tests remain green.
61
+
62
+ RED/GREEN evidence is required only when the parent explicitly activates strict TDD. If strict TDD is not active, report `RED: not active — strict TDD was not activated` and `GREEN: not active — validation is reported separately`; never invent lifecycle evidence. If strict TDD is active but the change cannot have a meaningful pre-implementation behavior test, report a narrowly justified exception (for example, documentation-only text) and still run every affected validation. Never claim RED/GREEN evidence that was not observed.
63
+
64
+ Run focused tests first. Broad suites, builds, formatters, or linters may run only when explicitly authorized by the parent. Keep every command exact and verify its scope before execution. Do not claim completion while required validation is failing.
65
+
66
+ ## Interaction contract
67
+
68
+ When any human input is required, stop editing and return the full schema in the Return contract with `status: interaction_required` and the nested `interaction_required` payload completed. Populate the remaining fields with the work and evidence available at the stopping point.
69
+
70
+ Do not return `blocked` for a human decision and do not invent a second interaction shape.
71
+
72
+ ## Return contract
73
+
74
+ Return one concise handoff using this schema:
75
+
76
+ ```text
77
+ status: completed | partial | blocked | interaction_required
78
+ summary: <what changed and why>
79
+ files_changed:
80
+ - <path>: <change>
81
+ tdd_evidence:
82
+ - RED: <observed failure, not active, or justified exception>
83
+ - GREEN: <observed pass, not active, or justified exception>
84
+ - TRIANGULATE/REFACTOR: <observed evidence when applicable>
85
+ validation:
86
+ - <exact command>: <observed result>
87
+ risks:
88
+ - <remaining risk or none>
89
+ review_focus:
90
+ - <paths or behaviors the fresh reviewer should inspect>
91
+ skill_resolution: paths-injected | paths-invalid | none
92
+ interaction_required: <include only when status is interaction_required>
93
+ question: <same deterministic interaction question>
94
+ reason: <same deterministic blocking reason>
95
+ options: <same meaningful choices and tradeoffs, when applicable>
96
+ unblock_response: <same exact context needed to continue>
97
+ ```
98
+
99
+ Use `skill_resolution: paths-injected` only when the parent injected exact skill paths and every path was successfully read before repository work. Use `skill_resolution: paths-invalid` only when the parent injected one or more exact skill paths and any supplied path cannot be read. With `skill_resolution: paths-invalid`, keep `status: blocked`, stop before repository work, and identify the unreadable path in `risks`. Use `skill_resolution: none` only when no skill paths were injected. Never report a fallback registry or path value.
100
+
101
+ Report `partial` or `blocked` honestly. A clean handoff is more valuable than pretending the task is complete.
@@ -44,11 +44,17 @@ Examples:
44
44
 
45
45
  Use the configured subagent runtime when available. Prefer the `subagent_*` tools (`subagent_run`, status/result helpers) when the Pi Subagents extension is installed, because they run the user's configured project/global subagent definitions and preserve history/background behavior.
46
46
 
47
+ The bounded multi-file writer precedence below is the explicit exception to this general runtime preference.
48
+
47
49
  Choose subagent mode by orchestration dependency, not by task length:
48
50
 
49
51
  - Use `mode: "task"` when the parent must consume the result and continue the workflow, including SDD phases, implementation batches, verification, review gates, and any delegated work whose output determines the next action.
50
52
  - Use `mode: "background"` only for independent work where automatic parent continuation is not required. Background completion may notify the user and preserve history, but it is not a guarantee that the parent model will resume orchestration.
51
53
 
54
+ For bounded multi-file writes, prefer the installed package-owned `gentle-ai-worker`, then a user-configured `worker`. If neither worker definition exists, fall back to the native `Agent` even when `subagent_*` tools are available. This writer precedence overrides the general runtime preference above.
55
+
56
+ For delegation other than bounded multi-file writes, use the generic fallback:
57
+
52
58
  If `subagent_*` tools are unavailable, fall back to Pi's native `Agent` tool or another available delegation mechanism. The delegation trigger remains mandatory; the fallback changes the runtime, not the requirement to delegate. If no delegation mechanism is available, stop the complex work and explain the blocker instead of silently continuing inline.
53
59
 
54
60
  ### Pi Subagent Model Routing
@@ -102,8 +108,12 @@ Core question: does this inflate parent context without need?
102
108
 
103
109
  These are parent-orchestrator stop rules. Once any trigger fires, the parent MUST delegate through the best available subagent runtime. Prefer `subagent_run` when present; otherwise use Pi's native `Agent` or another available delegation mechanism. Do not replace a required delegation with inline execution. Do not inject these as child-agent permission to spawn subagents; children receive concrete role work and must not orchestrate.
104
110
 
111
+ The bounded multi-file writer precedence in rule 2 overrides that general runtime preference. If no delegation mechanism is available, stop and explain the blocker.
112
+
105
113
  1. **4-file rule**: if understanding requires reading 4+ files, launch `scout`, `context-builder`, or the closest read-only mapping subagent with fresh context and a narrow mapping task. State the fallback agent/runtime if the preferred one is unavailable.
106
- 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer; inline writing is allowed only for trivial/mechanical edits or when the parent explicitly records why no delegation runtime is available. A fresh review still follows delegated implementation.
114
+ 2. **Multi-file write rule**: if implementation will touch 2+ non-trivial files, delegate one writer; inline writing is allowed only for trivial/mechanical edits. A fresh review still follows delegated implementation.
115
+ For bounded multi-file writes, prefer the installed package-owned `gentle-ai-worker`, then a user-configured `worker`. If neither worker definition exists, fall back to the native `Agent` even when `subagent_*` tools are available. If no delegation mechanism is available, stop and explain the blocker.
116
+
107
117
  3. **PR rule**: before commit/push/PR for code changes, select a fresh-context review lens unless the diff is trivial docs/text-only.
108
118
  4. **Incident rule**: after wrong `cwd`, accidental repo/worktree mutation, failed merge recovery, confusing test command, or environment workaround, stop and run a fresh audit through the relevant review lens before continuing.
109
119
  5. **Long-session rule**: if accumulating work is no longer clearly local — roughly 20 tool calls, 5 exploratory file reads, or 2 non-mechanical edits without delegation — pause and delegate the remaining work instead of silently continuing monolithically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -121,19 +121,19 @@ test(`getOrchestratorPrompt return value stays within the 10,240 B budget at a r
121
121
  //
122
122
  // Every normative line of the frozen pre-diet fixture is assigned to exactly
123
123
  // one documented disposition: CORE_VERBATIM (byte-identical in the new
124
- // core), or LAZY_VERBATIM (byte-identical in one specific lazy file, while
125
- // core carries a freshly-authored CORE_SUMMARIZED_INTO summary sentence that
126
- // is NOT literal-matched against the original line). Section headings that
127
- // are reused unchanged as the new core's summary heading are CORE_VERBATIM;
128
- // section bodies that are condensed away in core are LAZY_VERBATIM against
129
- // their one target lazy file — never a blanket union across all three.
124
+ // core), LAZY_VERBATIM (byte-identical in one specific lazy file), or OBSOLETE
125
+ // (intentionally absent from every live model-facing asset). Section headings
126
+ // that are reused unchanged as the new core's summary heading are
127
+ // CORE_VERBATIM; section bodies that are condensed away in core are
128
+ // LAZY_VERBATIM against their one target lazy file never a blanket union
129
+ // across all three.
130
130
  // ---------------------------------------------------------------------------
131
131
 
132
132
  type Target = "core" | "delegation" | "memory" | "skills";
133
133
 
134
134
  interface DispositionRange {
135
135
  lines: [number, number];
136
- target: Target;
136
+ target: Target | "obsolete";
137
137
  label: string;
138
138
  }
139
139
 
@@ -162,9 +162,19 @@ const DISPOSITION_MAP: DispositionRange[] = [
162
162
  { lines: [112, 112], target: "core", label: "Delegation Rules heading" },
163
163
  { lines: [114, 114], target: "core", label: "Delegation Rules core question" },
164
164
  {
165
- lines: [116, 181],
165
+ lines: [116, 132],
166
166
  target: "delegation",
167
- label: "Delegation Rules table + Mandatory Triggers + Cost/Context Balance + Canonical Workflows + Review Lens Selection",
167
+ label: "Delegation Rules table + Mandatory Triggers preamble",
168
+ },
169
+ {
170
+ lines: [133, 133],
171
+ target: "obsolete",
172
+ label: "Superseded no-runtime inline exception",
173
+ },
174
+ {
175
+ lines: [134, 181],
176
+ target: "delegation",
177
+ label: "Mandatory Triggers remainder + Cost/Context Balance + Canonical Workflows + Review Lens Selection",
168
178
  },
169
179
  { lines: [183, 191], target: "core", label: "SDD Workflow pointer" },
170
180
  { lines: [193, 193], target: "core", label: "Memory Contract heading" },
@@ -194,11 +204,21 @@ for (const range of DISPOSITION_MAP) {
194
204
  test(
195
205
  `disposition-mapped union: ${range.label} (fixture:${range.lines[0]}-${range.lines[1]}) -> ${range.target}`,
196
206
  () => {
197
- const targetContent = readRealAsset(TARGET_FILE[range.target]);
207
+ const targetContent =
208
+ range.target === "obsolete"
209
+ ? Object.values(TARGET_FILE).map(readRealAsset).join("\n")
210
+ : readRealAsset(TARGET_FILE[range.target]);
198
211
  for (let ln = range.lines[0]; ln <= range.lines[1]; ln++) {
199
212
  const raw = fixtureLines[ln - 1];
200
213
  if (raw === undefined || !isNormativeLine(raw)) continue;
201
214
  const trimmed = raw.trim();
215
+ if (range.target === "obsolete") {
216
+ assert.ok(
217
+ !targetContent.includes(trimmed),
218
+ `obsolete line retained: fixture:${ln} "${trimmed}" remains in a live model-facing asset (section: ${range.label})`,
219
+ );
220
+ continue;
221
+ }
202
222
  assert.ok(
203
223
  targetContent.includes(trimmed),
204
224
  `normative line lost: fixture:${ln} "${trimmed}" not found verbatim in ${TARGET_FILE[range.target]} (disposition: ${range.target}, section: ${range.label})`,
@@ -1,8 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
3
10
  import { dirname, join } from "node:path";
4
11
  import test from "node:test";
5
12
  import { fileURLToPath } from "node:url";
13
+ import { installSddAssets } from "../lib/sdd-preflight.ts";
6
14
 
7
15
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
16
 
@@ -71,6 +79,126 @@ function readAgentFrontmatter(file: string): string {
71
79
  return match[1];
72
80
  }
73
81
 
82
+ function readAgentDefinition(file: string): {
83
+ name: string;
84
+ source: string;
85
+ tools: string[];
86
+ } {
87
+ const source = readFileSync(file, "utf8");
88
+ const frontmatter = readAgentFrontmatter(file);
89
+ const name = frontmatter.match(/^name:\s*(\S+)$/m)?.[1];
90
+ assert.ok(name, `${file} must declare a frontmatter name`);
91
+ const toolsBlock = frontmatter.match(/^tools:\n((?: {2}- [\w-]+\n?)+)/m)?.[1];
92
+ assert.ok(toolsBlock, `${file} must declare a YAML tool list`);
93
+ const tools = [...toolsBlock.matchAll(/^ {2}- ([\w-]+)$/gm)].map(
94
+ (match) => match[1],
95
+ );
96
+
97
+ return { name, source, tools };
98
+ }
99
+
100
+ function readTextContract(source: string, heading: string): string {
101
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
+ const match = source.match(
103
+ new RegExp(`^## ${escapedHeading}\\n[\\s\\S]*?\\n\\x60\\x60\\x60text\\n([\\s\\S]*?)\\n\\x60\\x60\\x60`, "m"),
104
+ );
105
+ assert.ok(match, `${heading} must include a text contract block`);
106
+ return match[1];
107
+ }
108
+
109
+ function contractFields(contract: string, indentation = 0): string[] {
110
+ const prefix = " ".repeat(indentation);
111
+ return contract
112
+ .split("\n")
113
+ .flatMap((line) => {
114
+ const match = line.match(new RegExp(`^${prefix}([a-z_]+):`));
115
+ return match ? [match[1]] : [];
116
+ });
117
+ }
118
+
119
+ function nestedContractFields(contract: string, parent: string): string[] {
120
+ const lines = contract.split("\n");
121
+ const parentIndexes = lines.flatMap((line, index) =>
122
+ line.startsWith(`${parent}:`) ? [index] : [],
123
+ );
124
+ assert.equal(parentIndexes.length, 1, `${parent} must appear exactly once at top level`);
125
+
126
+ const tail = lines.slice(parentIndexes[0] + 1);
127
+ const relativeEnd = tail.findIndex((line) => /^\S/.test(line));
128
+ const nestedBlock = relativeEnd === -1 ? tail : tail.slice(0, relativeEnd);
129
+
130
+ return contractFields(nestedBlock.join("\n"), 2);
131
+ }
132
+
133
+ function readMarkdownSection(source: string, heading: string): string {
134
+ const lines = source.split(/\r?\n/);
135
+ const matches = lines.flatMap((line, index) => {
136
+ const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
137
+ return match?.[2] === heading
138
+ ? [{ index, level: match[1].length }]
139
+ : [];
140
+ });
141
+ assert.equal(matches.length, 1, `Markdown must contain exactly one ${heading} section`);
142
+
143
+ const [{ index: start, level }] = matches;
144
+ const relativeEnd = lines.slice(start + 1).findIndex((line) => {
145
+ const match = line.match(/^(#{1,6})\s+/);
146
+ return match !== null && match[1].length <= level;
147
+ });
148
+ const end = relativeEnd === -1 ? lines.length : start + 1 + relativeEnd;
149
+
150
+ return lines.slice(start + 1, end).join("\n").trim();
151
+ }
152
+
153
+ function assertWorkerFallbackRouting(section: string, sectionName: string): void {
154
+ const boundedWriterPolicy = section.match(
155
+ /For bounded multi-file writes,[\s\S]*?(?=\n\n|\n\s*\d+\.|$)/,
156
+ )?.[0];
157
+ assert.ok(boundedWriterPolicy, `${sectionName} must define bounded writer routing`);
158
+
159
+ const preferred = boundedWriterPolicy.indexOf("`gentle-ai-worker`");
160
+ const configuredFallback = boundedWriterPolicy.indexOf("user-configured `worker`");
161
+ const nativeFallback = boundedWriterPolicy.indexOf("native `Agent`");
162
+
163
+ assert.ok(preferred >= 0, `${sectionName} must reference exact gentle-ai-worker name`);
164
+ assert.ok(
165
+ configuredFallback > preferred,
166
+ `${sectionName} must prefer the package-owned worker before a user-configured worker`,
167
+ );
168
+ assert.ok(
169
+ nativeFallback > configuredFallback,
170
+ `${sectionName} must place native Agent after both named worker definitions`,
171
+ );
172
+ assert.match(
173
+ boundedWriterPolicy,
174
+ /If neither (?:worker )?definition exists[^.]*native `Agent`[^.]*even when `subagent_\*` tools are available\./,
175
+ `${sectionName} must choose native Agent when neither worker definition exists`,
176
+ );
177
+ assert.match(
178
+ section,
179
+ /If no delegation mechanism is available, stop/,
180
+ `${sectionName} must stop when delegation is impossible`,
181
+ );
182
+ }
183
+
184
+ test("Markdown section extraction isolates policy text from sibling sections", () => {
185
+ const markdown = [
186
+ "# Agent",
187
+ "## Context contract",
188
+ "context-only policy",
189
+ "### Context detail",
190
+ "nested context policy",
191
+ "## Tool safety",
192
+ "tool-only policy",
193
+ ].join("\n");
194
+
195
+ const context = readMarkdownSection(markdown, "Context contract");
196
+
197
+ assert.match(context, /context-only policy/);
198
+ assert.match(context, /nested context policy/);
199
+ assert.doesNotMatch(context, /tool-only policy/);
200
+ });
201
+
74
202
  test("packaged agents use YAML list syntax for tool allowlists", () => {
75
203
  const agentsDir = join(PACKAGE_ROOT, "assets", "agents");
76
204
  const agentFiles = readdirSync(agentsDir).flatMap((entry) =>
@@ -100,6 +228,184 @@ test("jd-fix-agent packaged allowlist includes write tools", () => {
100
228
  }
101
229
  });
102
230
 
231
+ test("gentle-ai-worker packages the exact scoped writer contract", () => {
232
+ const agentsDir = join(PACKAGE_ROOT, "assets", "agents");
233
+ const agentPath = join(agentsDir, "gentle-ai-worker.md");
234
+ assert.ok(existsSync(agentPath), "gentle-pi must package gentle-ai-worker.md");
235
+ for (const genericName of ["worker.md", "generic-writer.md"]) {
236
+ assert.ok(
237
+ !existsSync(join(agentsDir, genericName)),
238
+ `the package-owned writer must not use collision-prone ${genericName}`,
239
+ );
240
+ }
241
+
242
+ const { name, source, tools } = readAgentDefinition(agentPath);
243
+ assert.equal(name, "gentle-ai-worker");
244
+ assert.deepEqual(tools, [
245
+ "read",
246
+ "grep",
247
+ "find",
248
+ "edit",
249
+ "write",
250
+ "bash",
251
+ "mem_save",
252
+ ]);
253
+ assert.ok(
254
+ tools.every((tool) => !tool.startsWith("subagent_")),
255
+ "a subagent must not be able to delegate",
256
+ );
257
+ assert.ok(!tools.includes("glob"), "the unsupported glob tool must not return");
258
+
259
+ const interactionContract = readMarkdownSection(source, "Interaction contract");
260
+ assert.doesNotMatch(
261
+ interactionContract,
262
+ /```text/,
263
+ "the interaction section must not define a second normative envelope",
264
+ );
265
+ assert.match(interactionContract, /stop editing/i);
266
+ assert.match(interactionContract, /full schema in the Return contract/);
267
+ assert.match(interactionContract, /`status: interaction_required`/);
268
+ assert.match(interactionContract, /nested `interaction_required` payload/);
269
+
270
+ const returnContract = readTextContract(source, "Return contract");
271
+ assert.deepEqual(contractFields(returnContract), [
272
+ "status",
273
+ "summary",
274
+ "files_changed",
275
+ "tdd_evidence",
276
+ "validation",
277
+ "risks",
278
+ "review_focus",
279
+ "skill_resolution",
280
+ "interaction_required",
281
+ ]);
282
+ assert.deepEqual(nestedContractFields(returnContract, "interaction_required"), [
283
+ "question",
284
+ "reason",
285
+ "options",
286
+ "unblock_response",
287
+ ]);
288
+ assert.match(
289
+ returnContract,
290
+ /skill_resolution: paths-injected \| paths-invalid \| none/,
291
+ );
292
+ assert.equal(
293
+ (source.match(/```text/g) ?? []).length,
294
+ 1,
295
+ "the Return contract must be the single authoritative full handoff schema",
296
+ );
297
+ assert.doesNotMatch(source, /fallback-(?:registry|path)/);
298
+
299
+ const returnContractSection = readMarkdownSection(source, "Return contract");
300
+ assert.match(
301
+ returnContractSection,
302
+ /Use `skill_resolution: paths-invalid` only when the parent injected one or more exact skill paths and any supplied path cannot be read/,
303
+ );
304
+ assert.match(
305
+ returnContractSection,
306
+ /With `skill_resolution: paths-invalid`, keep `status: blocked`/,
307
+ );
308
+
309
+ const contextContract = readMarkdownSection(source, "Context contract");
310
+ assert.match(contextContract, /pre-existing untracked targets explicitly listed by the parent/);
311
+ assert.match(contextContract, /new files required by the delegated task/);
312
+
313
+ const implementationRules = readMarkdownSection(source, "Implementation rules");
314
+ assert.match(implementationRules, /`blocked` only for a non-human technical blocker/);
315
+
316
+ const toolSafety = readMarkdownSection(source, "Tool safety");
317
+ assert.match(toolSafety, /sensitive files/);
318
+ assert.match(toolSafety, /stage, commit, push, publish/);
319
+
320
+ const memorySafety = readMarkdownSection(source, "Memory safety");
321
+ assert.match(memorySafety, /secrets, credentials, personal data/);
322
+ assert.match(memorySafety, /raw untrusted repository/);
323
+
324
+ const testDiscipline = readMarkdownSection(source, "Test discipline");
325
+ assert.match(testDiscipline, /Strict TDD is active/);
326
+ assert.match(testDiscipline, /not active/);
327
+ assert.match(
328
+ testDiscipline,
329
+ /Broad suites, builds, formatters, or linters may run only when explicitly authorized by the parent\./,
330
+ );
331
+ assert.match(testDiscipline, /Keep every command exact and verify its scope before execution\./);
332
+ assert.doesNotMatch(testDiscipline, /clearly required by the repository contract/);
333
+ });
334
+
335
+ test("installSddAssets installs gentle-ai-worker with a loader-compatible scoped identity", () => {
336
+ const temporaryAgentHome = mkdtempSync(join(tmpdir(), "gentle-pi-agent-home-"));
337
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
338
+
339
+ try {
340
+ process.env.GENTLE_PI_AGENT_HOME = temporaryAgentHome;
341
+ installSddAssets(PACKAGE_ROOT, true);
342
+
343
+ const installedAgentsDir = join(temporaryAgentHome, "agents");
344
+ const installedAgentPath = join(installedAgentsDir, "gentle-ai-worker.md");
345
+ assert.ok(existsSync(installedAgentPath), "the production installer must install gentle-ai-worker.md");
346
+ for (const genericName of ["worker.md", "generic-writer.md"]) {
347
+ assert.ok(
348
+ !existsSync(join(installedAgentsDir, genericName)),
349
+ `the installer must not create collision-prone ${genericName}`,
350
+ );
351
+ }
352
+
353
+ const { name, source, tools } = readAgentDefinition(installedAgentPath);
354
+ const normalizedRuntimeIdentity = name.trim().toLowerCase();
355
+ assert.equal(normalizedRuntimeIdentity, "gentle-ai-worker");
356
+ assert.deepEqual(tools, [
357
+ "read",
358
+ "grep",
359
+ "find",
360
+ "edit",
361
+ "write",
362
+ "bash",
363
+ "mem_save",
364
+ ]);
365
+ assert.doesNotMatch(
366
+ readAgentFrontmatter(installedAgentPath),
367
+ /^package\s*:/m,
368
+ "package frontmatter must not alter external loader identity",
369
+ );
370
+ assert.doesNotMatch(source, /^name:\s*(?:worker|generic-writer)$/m);
371
+ } finally {
372
+ if (previousAgentHome === undefined) {
373
+ delete process.env.GENTLE_PI_AGENT_HOME;
374
+ } else {
375
+ process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
376
+ }
377
+ rmSync(temporaryAgentHome, { recursive: true, force: true });
378
+ }
379
+
380
+ assert.equal(process.env.GENTLE_PI_AGENT_HOME, previousAgentHome);
381
+ assert.ok(
382
+ !existsSync(temporaryAgentHome),
383
+ "the integration test must delete only its temporary agent home",
384
+ );
385
+ });
386
+
387
+ test("bounded implementation routing uses the same explicit fallback in both policy sections", () => {
388
+ const routing = readFileSync(
389
+ join(PACKAGE_ROOT, "assets", "orchestrator-delegation.md"),
390
+ "utf8",
391
+ );
392
+ const simpleDelegation = readMarkdownSection(routing, "2. Simple Delegation");
393
+ const mandatoryDelegation = readMarkdownSection(routing, "Mandatory Delegation Triggers");
394
+
395
+ assertWorkerFallbackRouting(simpleDelegation, "Simple Delegation");
396
+ assertWorkerFallbackRouting(mandatoryDelegation, "Mandatory Delegation Triggers");
397
+ assert.doesNotMatch(
398
+ routing,
399
+ /non-normative compatibility quotation|former wording is retained|no-runtime inline exception|superseded by the stop requirement/,
400
+ "model-facing routing must not retain contradictory dead prose",
401
+ );
402
+ assert.doesNotMatch(
403
+ routing,
404
+ /`generic-writer`/,
405
+ "routing must not revive the collision-prone generic package name",
406
+ );
407
+ });
408
+
103
409
  test("pi-pretty wrapper uses real package path resolution for pnpm symlink installs", () => {
104
410
  const wrapper = readFileSync(
105
411
  join(PACKAGE_ROOT, "extensions", "pi-pretty.ts"),