gentle-pi 2.7.0 → 3.0.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 (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -0,0 +1,370 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test, { after } from "node:test";
7
+ import { createProfile, emptyProfilesFile, profilesFilePath, writeProfilesFileSync } from "../lib/agent-profiles.ts";
8
+ import {
9
+ PROFILE_PIN_KIND,
10
+ PROFILE_PIN_VERSION,
11
+ REPO_PROFILE_DECLARATION_GITIGNORE_RULES,
12
+ clearProfilePinSync,
13
+ evaluateProfilePin,
14
+ localProfilePinPath,
15
+ normalizeProfilePin,
16
+ parseProfilePinText,
17
+ readProfilePin,
18
+ readProfilePinResult,
19
+ readProfilePinStatus,
20
+ repoProfileDeclarationPath,
21
+ resolveProfilePin,
22
+ serializeProfilePin,
23
+ setProfilePinWorktreeResolverForTesting,
24
+ writeProfilePinSync,
25
+ } from "../lib/agent-profile-pin.ts";
26
+ import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
27
+ import type { WorktreeIdentity } from "../lib/session-worktree-registry.ts";
28
+
29
+ // Per-repository agent-model profile pins: two layers holding only a profile name,
30
+ // resolved at launch against the global profiles store. These tests never touch
31
+ // Git; the worktree identity is injected, which is also how the launch path binds
32
+ // the pin to the repository the child will work in.
33
+
34
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-profile-pin-"));
35
+ after(() => rmSync(root, { recursive: true, force: true }));
36
+
37
+ function identityAt(name: string): WorktreeIdentity {
38
+ return { root: join(root, name, "worktree"), commonDir: join(root, name, "git-common") };
39
+ }
40
+
41
+ function storeAt(configHome: string, profiles: Record<string, AgentModelConfig>): void {
42
+ const file = Object.entries(profiles).reduce(
43
+ (current, [name, config]) => createProfile(current, name, config),
44
+ emptyProfilesFile(),
45
+ );
46
+ writeProfilesFileSync(profilesFilePath(configHome), file);
47
+ }
48
+
49
+ test("pin paths follow the shared clone and the worktree", () => {
50
+ assert.equal(localProfilePinPath("/clone/.git"), join("/clone/.git", "gentle-ai", "profile-pin.json"));
51
+ assert.equal(repoProfileDeclarationPath("/clone"), join("/clone", ".pi", "gentle-ai", "profile.json"));
52
+ });
53
+
54
+ test("the suggested ignore rules expose only the repository declaration", () => {
55
+ const repo = join(root, "ignored-declaration");
56
+ mkdirSync(repo, { recursive: true });
57
+ execFileSync("git", ["init", "--quiet", repo]);
58
+ writeFileSync(join(repo, ".gitignore"), ".pi/\n");
59
+ writeProfilePinSync(repoProfileDeclarationPath(repo), "team");
60
+ writeFileSync(join(repo, ".pi", "gentle-ai", "private.json"), "private\n");
61
+ writeFileSync(join(repo, ".pi", "other.json"), "other\n");
62
+ writeFileSync(
63
+ join(repo, ".gitignore"),
64
+ `.pi/\n${REPO_PROFILE_DECLARATION_GITIGNORE_RULES.join("\n")}\n`,
65
+ );
66
+ const visible = execFileSync(
67
+ "git",
68
+ ["-C", repo, "ls-files", "--others", "--exclude-standard", "--", ".pi"],
69
+ { encoding: "utf8" },
70
+ ).trim().split(/\r?\n/).filter(Boolean);
71
+ assert.deepEqual(visible, [".pi/gentle-ai/profile.json"]);
72
+ });
73
+
74
+ test("the pin artifact is byte-identical for the same profile", () => {
75
+ const text = serializeProfilePin("deep-work");
76
+ assert.equal(
77
+ text,
78
+ [
79
+ "{",
80
+ ` "kind": "${PROFILE_PIN_KIND}",`,
81
+ ` "version": ${PROFILE_PIN_VERSION},`,
82
+ ' "profile": "deep-work"',
83
+ "}",
84
+ "",
85
+ ].join("\n"),
86
+ );
87
+ assert.equal(serializeProfilePin("deep-work"), text, "stable key order and indentation");
88
+ assert.deepEqual(parseProfilePinText(text), { status: "valid", profile: "deep-work" });
89
+ });
90
+
91
+ test("the pin parser rejects foreign kinds, versions, names, and malformed input", () => {
92
+ const invalid = [
93
+ "{",
94
+ "[]",
95
+ "null",
96
+ '"deep-work"',
97
+ JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profile: "deep-work" }),
98
+ JSON.stringify({ kind: PROFILE_PIN_KIND, version: 2, profile: "deep-work" }),
99
+ JSON.stringify({ kind: PROFILE_PIN_KIND, version: 1 }),
100
+ JSON.stringify({ kind: PROFILE_PIN_KIND, version: 1, profile: "not a name" }),
101
+ JSON.stringify({ kind: PROFILE_PIN_KIND, version: 1, profile: "__proto__" }),
102
+ JSON.stringify({ kind: PROFILE_PIN_KIND, version: 1, profile: "" }),
103
+ ];
104
+ for (const text of invalid) assert.deepEqual(parseProfilePinText(text), { status: "invalid" }, text);
105
+ });
106
+
107
+ test("writeProfilePinSync creates parents, round-trips, and leaves no temp file behind", () => {
108
+ const path = join(root, "write", "gentle-ai", "profile-pin.json");
109
+ writeProfilePinSync(path, "deep-work");
110
+ assert.equal(readFileSync(path, "utf8"), serializeProfilePin("deep-work"));
111
+ assert.equal(readProfilePin(path), "deep-work");
112
+ writeProfilePinSync(path, "team");
113
+ assert.equal(readProfilePin(path), "team");
114
+ assert.deepEqual(readdirSync(join(root, "write", "gentle-ai")).filter((entry) => entry.includes(".tmp")), []);
115
+ assert.equal(readProfilePin(join(root, "write", "absent.json")), undefined);
116
+ });
117
+
118
+ test("an invalid pin file reads as no pin, and an illegal name is refused before writing", () => {
119
+ const dir = join(root, "invalid");
120
+ mkdirSync(dir, { recursive: true });
121
+ const malformed = join(dir, "profile-pin.json");
122
+ writeFileSync(malformed, "{ not json\n");
123
+ assert.equal(readProfilePin(malformed), undefined);
124
+ writeFileSync(malformed, JSON.stringify({ kind: PROFILE_PIN_KIND, version: 1, profile: "bad name" }));
125
+ assert.equal(readProfilePin(malformed), undefined);
126
+ const refused = join(dir, "refused.json");
127
+ assert.throws(() => writeProfilePinSync(refused, "bad name"), /Invalid profile name/);
128
+ assert.equal(existsSync(refused), false);
129
+ });
130
+
131
+ test("readProfilePinStatus reads both layers and reports nothing outside a Git worktree", () => {
132
+ const identity = identityAt("status");
133
+ // A local pin alone: the declaration is still reported as absent rather than
134
+ // inferred from the layer that won.
135
+ mkdirSync(identity.commonDir, { recursive: true });
136
+ writeProfilePinSync(localProfilePinPath(identity.commonDir), "team");
137
+ const status = readProfilePinStatus(identity.root, () => identity);
138
+ assert.ok(status);
139
+ assert.equal(status.root, identity.root);
140
+ assert.equal(status.commonDir, identity.commonDir);
141
+ assert.equal(status.localPath, localProfilePinPath(identity.commonDir));
142
+ assert.equal(status.repoPath, repoProfileDeclarationPath(identity.root));
143
+ assert.deepEqual(status.local, { status: "valid", profile: "team" });
144
+ assert.deepEqual(status.repo, { status: "missing" });
145
+ assert.equal(readProfilePinStatus(identity.root, () => undefined), undefined);
146
+ assert.equal(
147
+ readProfilePinStatus(identity.root, () => {
148
+ throw new Error("git is unavailable");
149
+ }),
150
+ undefined,
151
+ "a failing resolver reads as no pin instead of failing the panel",
152
+ );
153
+ });
154
+
155
+ test("an ambient Git identity miss is retried after the directory becomes a repository", () => {
156
+ const repo = join(root, "late-git-init");
157
+ mkdirSync(repo, { recursive: true });
158
+ assert.equal(readProfilePinStatus(repo), undefined);
159
+ execFileSync("git", ["init", "--quiet", repo]);
160
+ const status = readProfilePinStatus(repo);
161
+ assert.ok(status, "a prior miss must not hide a Git identity that appeared later");
162
+ assert.equal(status.repoPath, repoProfileDeclarationPath(status.root));
163
+ });
164
+
165
+ test("an ambient Git identity is resolved again after the repository identity changes", () => {
166
+ const cwd = join(root, "replaced-repository");
167
+ const before = identityAt("replaced-before");
168
+ const afterReplacement = identityAt("replaced-after");
169
+ let calls = 0;
170
+ setProfilePinWorktreeResolverForTesting(() => {
171
+ calls += 1;
172
+ return calls === 1 ? before : afterReplacement;
173
+ });
174
+ try {
175
+ assert.equal(readProfilePinStatus(cwd)?.root, before.root);
176
+ assert.equal(readProfilePinStatus(cwd)?.root, afterReplacement.root);
177
+ assert.equal(calls, 2, "each status read observes the repository identity that exists now");
178
+ } finally {
179
+ setProfilePinWorktreeResolverForTesting();
180
+ }
181
+ });
182
+
183
+ test("resolveProfilePin prefers the local pin and returns role entries without the orchestrator", () => {
184
+ const identity = identityAt("precedence");
185
+ const configHome = join(root, "precedence", "config");
186
+ storeAt(configHome, {
187
+ "local-choice": {
188
+ orchestrator: { model: "nan/glm5.3" },
189
+ explore: { model: "openai/alpha", thinking: "minimal" },
190
+ },
191
+ "declared-choice": { explore: { model: "openai/beta" } },
192
+ });
193
+ writeProfilePinSync(localProfilePinPath(identity.commonDir), "local-choice");
194
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "declared-choice");
195
+ const resolved = resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity });
196
+ assert.ok(resolved);
197
+ assert.equal(resolved.source, "local");
198
+ assert.equal(resolved.profile, "local-choice");
199
+ assert.equal(resolved.path, localProfilePinPath(identity.commonDir));
200
+ assert.deepEqual(resolved.modelProfiles, { explore: { model: "openai/alpha", thinking: "minimal" } });
201
+ assert.equal("orchestrator" in resolved.modelProfiles, false, "the orchestrator is not subagent routing");
202
+ });
203
+
204
+ test("resolveProfilePin falls back to the repository declaration", () => {
205
+ const identity = identityAt("declaration");
206
+ const configHome = join(root, "declaration", "config");
207
+ storeAt(configHome, { declared: { explore: { model: "openai/beta" } } });
208
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "declared");
209
+ const resolved = resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity });
210
+ assert.ok(resolved);
211
+ assert.equal(resolved.source, "repo");
212
+ assert.equal(resolved.profile, "declared");
213
+ assert.equal(resolved.path, repoProfileDeclarationPath(identity.root));
214
+ assert.deepEqual(resolved.modelProfiles, { explore: { model: "openai/beta", thinking: undefined } });
215
+ });
216
+
217
+ test("a stale or invalid local layer does not mask a usable repository declaration", () => {
218
+ const identity = identityAt("stale");
219
+ const configHome = join(root, "stale", "config");
220
+ storeAt(configHome, { declared: { explore: { model: "openai/beta" } } });
221
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "declared");
222
+ // The local pin names a profile the store no longer defines, which must degrade
223
+ // to the next layer instead of failing every launch in the clone.
224
+ writeProfilePinSync(localProfilePinPath(identity.commonDir), "deleted-profile");
225
+ assert.equal(
226
+ resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity })?.profile,
227
+ "declared",
228
+ );
229
+ writeFileSync(localProfilePinPath(identity.commonDir), "{ not json\n");
230
+ assert.equal(
231
+ resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity })?.profile,
232
+ "declared",
233
+ );
234
+ });
235
+
236
+ test("with no usable layer and no readable store, resolveProfilePin reports no pin", () => {
237
+ const identity = identityAt("absent");
238
+ const configHome = join(root, "absent", "config");
239
+ storeAt(configHome, { team: { explore: { model: "openai/alpha" } } });
240
+ // No pin file at all: today's global routing, and the store is not even read.
241
+ assert.equal(resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity }), undefined);
242
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "team");
243
+ // A store that was deleted or corrupted leaves the pin unresolvable; the launch
244
+ // keeps the global routing instead of throwing.
245
+ const missingStore = join(root, "absent", "missing-config");
246
+ assert.equal(
247
+ resolveProfilePin({ cwd: identity.root, configHome: missingStore, resolveWorktree: () => identity }),
248
+ undefined,
249
+ );
250
+ const invalidStore = join(root, "absent", "invalid-config");
251
+ mkdirSync(invalidStore, { recursive: true });
252
+ writeFileSync(profilesFilePath(invalidStore), "{ not json\n");
253
+ assert.equal(
254
+ resolveProfilePin({ cwd: identity.root, configHome: invalidStore, resolveWorktree: () => identity }),
255
+ undefined,
256
+ );
257
+ // A session outside every Git worktree can never carry a pin.
258
+ assert.equal(resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => undefined }), undefined);
259
+ });
260
+
261
+ test("the read result separates a missing pin from a file that is not a pin", () => {
262
+ const dir = join(root, "read-result");
263
+ mkdirSync(dir, { recursive: true });
264
+ const missing = join(dir, "missing.json");
265
+ assert.deepEqual(readProfilePinResult(missing), { status: "missing" });
266
+ const invalid = join(dir, "invalid.json");
267
+ writeFileSync(invalid, "{ not json\n");
268
+ assert.deepEqual(readProfilePinResult(invalid), { status: "invalid" });
269
+ const valid = join(dir, "valid.json");
270
+ writeProfilePinSync(valid, "team");
271
+ assert.deepEqual(readProfilePinResult(valid), { status: "valid", profile: "team" });
272
+ });
273
+
274
+ test("clearProfilePinSync removes a pin and tolerates an already-missing file", () => {
275
+ const path = join(root, "clear", "gentle-ai", "profile-pin.json");
276
+ writeProfilePinSync(path, "team");
277
+ clearProfilePinSync(path);
278
+ assert.equal(existsSync(path), false);
279
+ assert.doesNotThrow(() => clearProfilePinSync(path), "clearing a missing pin is already the desired state");
280
+ });
281
+
282
+ test("evaluateProfilePin reports the winner, invalid files by path, and stale names without hiding a lower layer", () => {
283
+ const identity = identityAt("evaluate");
284
+ mkdirSync(identity.commonDir, { recursive: true });
285
+ // No layers at all: nothing wins and nothing is reported.
286
+ assert.deepEqual(evaluateProfilePin(readProfilePinStatus(identity.root, () => identity), { team: {} }), { invalid: [], stale: [] });
287
+ // A stale local layer is reported AND does not mask a usable declaration.
288
+ writeProfilePinSync(localProfilePinPath(identity.commonDir), "deleted-profile");
289
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "declared");
290
+ const evaluation = evaluateProfilePin(readProfilePinStatus(identity.root, () => identity), { declared: {} });
291
+ assert.deepEqual(evaluation.winner, {
292
+ source: "repo",
293
+ profile: "declared",
294
+ path: repoProfileDeclarationPath(identity.root),
295
+ });
296
+ assert.deepEqual(evaluation.stale, [{
297
+ source: "local",
298
+ profile: "deleted-profile",
299
+ path: localProfilePinPath(identity.commonDir),
300
+ }]);
301
+ // A file that is not a pin is reported by its exact path, separately from a stale
302
+ // name, and still does not mask the declaration below it.
303
+ writeFileSync(localProfilePinPath(identity.commonDir), "{ not json\n");
304
+ const invalid = evaluateProfilePin(readProfilePinStatus(identity.root, () => identity), { declared: {} });
305
+ assert.deepEqual(invalid.invalid, [{ source: "local", path: localProfilePinPath(identity.commonDir) }]);
306
+ assert.deepEqual(invalid.winner, {
307
+ source: "repo",
308
+ profile: "declared",
309
+ path: repoProfileDeclarationPath(identity.root),
310
+ });
311
+ });
312
+
313
+ test("resolveProfilePin carries both read layers and the invalid and stale reports", () => {
314
+ const identity = identityAt("carry");
315
+ const configHome = join(root, "carry", "config");
316
+ storeAt(configHome, { declared: { explore: { model: "openai/beta" } } });
317
+ writeProfilePinSync(repoProfileDeclarationPath(identity.root), "declared");
318
+ // Create the local layer as a real pin first, then corrupt it, so the parent
319
+ // directory exists without depending on any other helper.
320
+ writeProfilePinSync(localProfilePinPath(identity.commonDir), "deleted-profile");
321
+ writeFileSync(localProfilePinPath(identity.commonDir), "{ not json\n");
322
+ const resolved = resolveProfilePin({ cwd: identity.root, configHome, resolveWorktree: () => identity });
323
+ assert.ok(resolved);
324
+ assert.equal(resolved.profile, "declared");
325
+ assert.deepEqual(resolved.status.local, { status: "invalid" });
326
+ assert.deepEqual(resolved.status.repo, { status: "valid", profile: "declared" });
327
+ assert.deepEqual(resolved.invalidLayers, [{ source: "local", path: localProfilePinPath(identity.commonDir) }]);
328
+ assert.deepEqual(resolved.staleLayers, []);
329
+ });
330
+
331
+ test("two worktrees of one clone read the same clone-local pin but keep their own declaration", () => {
332
+ const commonDir = join(root, "shared", "git-common");
333
+ const firstWorktree = join(root, "shared", "worktree-a");
334
+ const secondWorktree = join(root, "shared", "worktree-b");
335
+ writeProfilePinSync(localProfilePinPath(commonDir), "team");
336
+ const first = readProfilePinStatus(firstWorktree, () => ({ root: firstWorktree, commonDir }));
337
+ const second = readProfilePinStatus(secondWorktree, () => ({ root: secondWorktree, commonDir }));
338
+ assert.deepEqual(first?.local, { status: "valid", profile: "team" });
339
+ assert.deepEqual(second?.local, { status: "valid", profile: "team" }, "the local pin lives in the shared Git common directory");
340
+ assert.deepEqual(second?.repo, { status: "missing" }, "the declaration is per worktree, not shared with the sibling");
341
+ });
342
+
343
+ test("each status read resolves the current worktree identity and injected resolvers stay isolated", () => {
344
+ let calls = 0;
345
+ const injected = identityAt("memo-injected");
346
+ setProfilePinWorktreeResolverForTesting((cwd) => {
347
+ calls += 1;
348
+ return { root: join(cwd, "worktree"), commonDir: join(cwd, "git-common") };
349
+ });
350
+ try {
351
+ readProfilePinStatus(join(root, "memo-a"));
352
+ readProfilePinStatus(join(root, "memo-a"));
353
+ assert.equal(calls, 2, "each status read resolves the identity that exists now");
354
+ readProfilePinStatus(join(root, "memo-b"));
355
+ assert.equal(calls, 3, "a second directory resolves its own identity");
356
+ // A resolver injected per call remains independent from the ambient seam.
357
+ readProfilePinStatus(join(root, "memo-a"), () => injected);
358
+ assert.equal(calls, 3, "the injected resolver does not touch the ambient resolver");
359
+ } finally {
360
+ setProfilePinWorktreeResolverForTesting();
361
+ }
362
+ });
363
+
364
+ test("normalizeProfilePin accepts only a value-level pin this version writes", () => {
365
+ assert.equal(normalizeProfilePin({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "deep-work" }), "deep-work");
366
+ assert.equal(normalizeProfilePin({ kind: PROFILE_PIN_KIND, version: 2, profile: "deep-work" }), undefined);
367
+ assert.equal(normalizeProfilePin({ kind: "other", version: PROFILE_PIN_VERSION, profile: "deep-work" }), undefined);
368
+ assert.equal(normalizeProfilePin({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "bad name" }), undefined);
369
+ assert.equal(normalizeProfilePin("deep-work"), undefined);
370
+ });
@@ -71,6 +71,7 @@ function createPi(options: { throwOnToolConflict?: boolean } = {}) {
71
71
  const hooks = new Map<string, any[]>();
72
72
  return {
73
73
  tools,
74
+ hooks,
74
75
  pi: {
75
76
  registerTool(tool: any) {
76
77
  if (options.throwOnToolConflict && tools.has(tool.name)) {
@@ -232,9 +233,35 @@ test("pi-pretty suppresses overlapping tools before quiet tools register", async
232
233
  );
233
234
  });
234
235
 
236
+ test("pi-pretty preserves byte-exact model-visible read results when quiet tools suppress its renderer", async () => {
237
+ await withEnvAsync(
238
+ { GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: undefined },
239
+ async () => {
240
+ const { pi, hooks } = createPi();
241
+ await piPretty(pi as any, fakePiPrettyDeps as any);
242
+
243
+ const original = "[alpha]\ntarget=old\n\n[beta]\ntarget=old\n";
244
+ let event: any = {
245
+ toolName: "read",
246
+ content: [{ type: "text", text: original }],
247
+ };
248
+ for (const handler of hooks.get("tool_result") ?? []) {
249
+ const replacement = await handler(event, {});
250
+ if (replacement) event = { ...event, ...replacement };
251
+ }
252
+
253
+ assert.equal(event.content[0]?.text, original);
254
+ },
255
+ );
256
+ });
257
+
235
258
  test("pi-pretty suppression is skipped when quiet tools are disabled", async () => {
236
259
  await withEnvAsync(
237
- { GENTLE_PI_QUIET_TOOLS: "0", PRETTY_DISABLE_TOOLS: undefined },
260
+ {
261
+ GENTLE_PI_QUIET_TOOLS: "0",
262
+ PRETTY_DISABLE_TOOLS: undefined,
263
+ PRETTY_ENABLE_TOOLS: "ls",
264
+ },
238
265
  async () => {
239
266
  const { pi, tools } = createPi();
240
267
 
@@ -426,8 +453,8 @@ test("quiet tool rendering identifies only routine Gentle AI SDD and RDD command
426
453
  assert.equal(gentleAiRoutineCommand({ command: "./.gentle-ai/v2.2.0/gentle-ai sdd-status rose --json" }), "sdd-status");
427
454
  assert.equal(gentleAiRoutineCommand({ command: ".\\.gentle-ai\\v2.2.0\\gentle-ai.exe review status --next-transition" }), "review");
428
455
  assert.equal(gentleAiRoutineCommand({ command: "C:\\package\\.gentle-ai\\v2.2.0\\gentle-ai.exe sdd-continue rose" }), "sdd-continue");
429
- assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt acquire --change fix-rose" }), "sdd-attempt");
430
- assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt settle --change fix-rose" }), "sdd-attempt");
456
+ assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt acquire --change fix-rose" }), undefined);
457
+ assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt settle --change fix-rose" }), undefined);
431
458
  assert.equal(gentleAiRoutineCommand({ command: "gentle-ai review status --next-transition" }), "review");
432
459
  assert.equal(gentleAiRoutineCommand({ command: "gentle-ai version" }), undefined);
433
460
  assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt inspect" }), undefined);
@@ -591,8 +618,8 @@ test("quiet tool rendering displays only finite safe Gentle AI operation paths",
591
618
  const cases = [
592
619
  ["gentle-ai sdd-status change-123 --cwd /repo/private", "sdd status"],
593
620
  ["gentle-ai sdd-continue change-123 --json", "sdd continue"],
594
- ["gentle-ai sdd-attempt acquire --change change-123", "sdd attempt acquire"],
595
- ["gentle-ai sdd-attempt settle --change change-123 --actor maintainer", "sdd attempt settle"],
621
+ ["gentle-ai sdd-attempt acquire --change change-123", "sdd attempt"],
622
+ ["gentle-ai sdd-attempt settle --change change-123 --actor maintainer", "sdd attempt"],
596
623
  ["gentle-ai review capabilities --cwd /repo/private", "review capabilities"],
597
624
  ["gentle-ai review start --target sha256:secret --path src/private.ts", "review start"],
598
625
  ["gentle-ai review finalize --lineage lineage-secret --payload '{\"secret\":true}'", "review finalize"],
@@ -62,6 +62,12 @@ test("before_agent_start injects the mirrored review execution contract for the
62
62
  const { beforeAgentStart } = harness({} as NativeReviewCli);
63
63
  const result = await beforeAgentStart(primaryEvent, ctx());
64
64
  const expected = mirroredPiOrchestrationText();
65
+ assert.match(result.systemPrompt, /Substantial authorized work: use ODD/);
66
+ assert.match(result.systemPrompt, /Use configured TDD mode, source, and exact runner/);
67
+ assert.match(result.systemPrompt, /test presence does not enable it/);
68
+ assert.doesNotMatch(result.systemPrompt, /If tests exist, use strict TDD/);
69
+ assert.match(result.systemPrompt, /ODD \(Default Workflow, harness section above\) is mandatory on every request/);
70
+ assert.doesNotMatch(result.systemPrompt, /Prefer SDD\/OpenSpec artifacts/);
65
71
  assert.match(result.systemPrompt, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
66
72
  assert.ok(result.systemPrompt.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
67
73
  assert.match(result.systemPrompt, /call `gentle_review` with {"operation":"inspect"}/);
@@ -97,6 +103,7 @@ test("before_agent_start does not inject the review execution contract for gentl
97
103
  const { beforeAgentStart } = harness({} as NativeReviewCli);
98
104
  const result = await beforeAgentStart({ agentName: "gentle-ai-worker", systemPrompt: "base" }, ctx());
99
105
  assert.equal(result.systemPrompt, "base");
106
+ assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
100
107
  assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
101
108
  });
102
109
 
@@ -104,12 +111,14 @@ test("before_agent_start does not inject the review execution contract for jd-fi
104
111
  const { beforeAgentStart } = harness({} as NativeReviewCli);
105
112
  const result = await beforeAgentStart({ agentName: "jd-fix-agent", systemPrompt: "base" }, ctx());
106
113
  assert.equal(result.systemPrompt, "base");
114
+ assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
107
115
  assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
108
116
  });
109
117
 
110
118
  test("before_agent_start does not inject the review execution contract for an SDD executor session", async () => {
111
119
  const { beforeAgentStart } = harness({} as NativeReviewCli);
112
120
  const result = await beforeAgentStart({ systemPrompt: "SDD apply executor body" }, ctx());
121
+ assert.doesNotMatch(result.systemPrompt, /Substantial authorized work: use ODD/);
113
122
  assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
114
123
  });
115
124
 
@@ -189,50 +189,6 @@ function createTerminalAuthority(fixture: RepositoryFixture, lineageId: string,
189
189
  }
190
190
  }
191
191
 
192
- test("controller SDD status treats removed OpenSpec recovery authority and deleted marker as blocking", async (t) => {
193
- const fixture = createRepository(t);
194
- const changeName = "recover-legacy-review-authority";
195
- const changeRoot = join(fixture.repository, "openspec", "changes", changeName);
196
- mkdirSync(changeRoot, { recursive: true });
197
- writeFileSync(join(fixture.repository, "app.ts"), "export const value = 2;\n");
198
- git(fixture.repository, "add", "app.ts");
199
- const completeTree = git(fixture.repository, "write-tree");
200
- createTerminalAuthority(fixture, "archived-graph-source", completeTree);
201
- const supersessionRoot = join(resolveRepositoryAuthorityV1(fixture.repository).store_root, "control", "authority-supersession-v1");
202
- const marker = join(supersessionRoot, "recovery-required-v1", `${domainHashV1("openspec-change-name", changeName)}.json`);
203
- mkdirSync(join(supersessionRoot, "recovery-required-v1"), { recursive: true });
204
- writeFileSync(marker, "recovery-required");
205
- rmSync(changeRoot, { recursive: true, force: true });
206
- unlinkSync(marker);
207
-
208
- const status = await __testing.resolveControllerSddStatus(fixture.repository, changeName, false, "openspec");
209
-
210
- assert.equal(status.dependencies.archive, "blocked");
211
- assert.equal(status.nextRecommended, "blocked");
212
- assert.match(status.blockedReasons.join("\n"), /active change not found/i);
213
- });
214
-
215
- test("controller SDD status ignores recovery-required review markers after terminal burn", async (t) => {
216
- const fixture = createRepository(t);
217
- const changeName = "recover-legacy-review-authority";
218
- const changeRoot = join(fixture.repository, "openspec", "changes", changeName);
219
- mkdirSync(join(changeRoot, "specs", "review"), { recursive: true });
220
- writeFileSync(join(changeRoot, "proposal.md"), "# Proposal\n");
221
- writeFileSync(join(changeRoot, "specs", "review", "spec.md"), "# Spec\n");
222
- writeFileSync(join(changeRoot, "design.md"), "# Design\n");
223
- writeFileSync(join(changeRoot, "tasks.md"), "- [x] 1.1 Done\n");
224
- writeFileSync(join(changeRoot, "verify-report.md"), "PASS\n");
225
- writeFileSync(join(changeRoot, "sync-report.md"), "PASS\n");
226
- const markerDirectory = join(resolveRepositoryAuthorityV1(fixture.repository).store_root, "control", "authority-supersession-v1", "recovery-required-v1");
227
- mkdirSync(markerDirectory, { recursive: true });
228
- writeFileSync(join(markerDirectory, `${domainHashV1("openspec-change-name", changeName)}.json`), "recovery-required");
229
-
230
- const status = await __testing.resolveControllerSddStatus(fixture.repository, changeName, false, "openspec");
231
-
232
- assert.equal(status.dependencies.archive, "ready");
233
- assert.equal(status.nextRecommended, "sdd-archive");
234
- });
235
-
236
192
  test("controller keeps graph-v1 ordinary mutation read-only while preserving repository-file input confinement", async (t) => {
237
193
  const fixture = createRepository(t);
238
194
  const lineageId = "controller-file-validator";