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.
- package/README.md +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- 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
|
-
{
|
|
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" }),
|
|
430
|
-
assert.equal(gentleAiRoutineCommand({ command: "gentle-ai sdd-attempt settle --change fix-rose" }),
|
|
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
|
|
595
|
-
["gentle-ai sdd-attempt settle --change change-123 --actor maintainer", "sdd attempt
|
|
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";
|