@juicesharp/rpiv-pi 1.19.1 → 1.20.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 +15 -5
- package/extensions/rpiv-core/agents.test.ts +39 -294
- package/extensions/rpiv-core/agents.ts +20 -73
- package/extensions/rpiv-core/artifact-collector.ts +4 -4
- package/extensions/rpiv-core/banner.test.ts +27 -0
- package/extensions/rpiv-core/banner.ts +23 -0
- package/extensions/rpiv-core/built-in-workflows.test.ts +34 -33
- package/extensions/rpiv-core/built-in-workflows.ts +25 -25
- package/extensions/rpiv-core/index.ts +2 -1
- package/extensions/rpiv-core/model-override.test.ts +106 -28
- package/extensions/rpiv-core/model-override.ts +59 -31
- package/extensions/rpiv-core/outcome-derivation.test.ts +156 -23
- package/extensions/rpiv-core/outcome-derivation.ts +35 -4
- package/extensions/rpiv-core/paths.test.ts +2 -2
- package/extensions/rpiv-core/session-hooks.test.ts +3 -4
- package/extensions/rpiv-core/session-hooks.ts +16 -14
- package/extensions/rpiv-core/sibling-import-graph.test.ts +115 -0
- package/extensions/rpiv-core/skill-contracts-source.test.ts +195 -4
- package/extensions/rpiv-core/skill-contracts-source.ts +90 -13
- package/package.json +3 -3
- package/skills/_shared/now.test.ts +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Skill-based development workflow for [Pi Agent](https://github.com/badlogic/pi-m
|
|
|
19
19
|
## What you get
|
|
20
20
|
|
|
21
21
|
- **A pipeline of chained AI skills** - discover → research → design → plan → implement → validate, each producing a reviewable artifact under `.rpiv/artifacts/`.
|
|
22
|
-
- **Named subagents for parallel analysis** - `codebase-analyzer`, `codebase-locator`, `codebase-pattern-finder`, `claim-verifier`, and
|
|
22
|
+
- **Named subagents for parallel analysis** - `codebase-analyzer`, `codebase-locator`, `codebase-pattern-finder`, `claim-verifier`, and 11 more, dispatched automatically by skills.
|
|
23
23
|
- **Session lifecycle hooks** - agent profiles and guidance files install themselves on first launch.
|
|
24
24
|
|
|
25
25
|
## Prerequisites
|
|
@@ -84,7 +84,7 @@ pi install npm:@juicesharp/rpiv-pi
|
|
|
84
84
|
4. *(Optional)* Configure web search:
|
|
85
85
|
|
|
86
86
|
```
|
|
87
|
-
/web-
|
|
87
|
+
/web-tools
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
### First Session
|
|
@@ -162,7 +162,11 @@ Invoke via `/skill:<name>` from inside a Pi Agent session.
|
|
|
162
162
|
| Skill | Description |
|
|
163
163
|
|---|---|
|
|
164
164
|
| `code-review` | Comprehensive code reviews using specialist row-only agents (`diff-auditor`, `peer-comparator`, `claim-verifier`) at narrativisation-prone dispatch sites |
|
|
165
|
+
| `architecture-review` | Top-down, layer-by-layer architecture review with a uniform 10-dimension checklist per layer; emits a phased polish plan under `.rpiv/artifacts/architecture-reviews/` |
|
|
166
|
+
| `pr-triage` | Read-only triage of a GitHub PR: disposition (Review / Request changes / Hold / Decline) plus a security tier (0 SAFE / 1 REVIEW / 2 BLOCK); never mutates the working tree |
|
|
165
167
|
| `commit` | Structured git commits grouped by logical change |
|
|
168
|
+
| `changelog` | Regenerate `[Unreleased]` CHANGELOG.md sections from Conventional-Commit history - Keep-a-Changelog style, monorepo-aware, idempotent |
|
|
169
|
+
| `frontend-design` | Inject tailored visual design guidance for web-frontend work; auto-adapts from a 2-question micro-interview to scan-only injection based on the project's style system |
|
|
166
170
|
| `create-handoff` | Context-preserving handoff documents for session transitions |
|
|
167
171
|
| `resume-handoff` | Resume work from a handoff document |
|
|
168
172
|
|
|
@@ -176,7 +180,9 @@ Invoke via `/skill:<name>` from inside a Pi Agent session.
|
|
|
176
180
|
| `/btw` | Ask a side question without polluting the main conversation _(requires `@juicesharp/rpiv-btw`, opt-in)_ |
|
|
177
181
|
| `/languages` | Pick the UI language for rpiv-* TUI strings (Deutsch / English / Español / Français / Português / Português (Brasil) / Русский / Українська) |
|
|
178
182
|
| `/todos` | Show current todo list |
|
|
179
|
-
| `/web-
|
|
183
|
+
| `/web-tools` | Pick the active search provider and set its API key |
|
|
184
|
+
| `/wf` | Run a workflow: `/wf` previews every flow, `/wf <name>` shows one's graph, `/wf <name> "task"` runs it, `/wf @<run-id>` resumes _(ships with `@juicesharp/rpiv-workflow`, installed by `/rpiv-setup`)_ |
|
|
185
|
+
| `/rpiv-models` | Pick model + reasoning-effort overrides per default, agent, skill, workflow stage, or preset stage (see **Model configuration** below) |
|
|
180
186
|
|
|
181
187
|
### Agents
|
|
182
188
|
|
|
@@ -192,6 +198,10 @@ Agents are dispatched automatically by skills via the `Agent` tool - you don't i
|
|
|
192
198
|
| `integration-scanner` | Maps inbound references, outbound dependencies, config registrations, and event subscriptions for a component |
|
|
193
199
|
| `peer-comparator` | Compares a new file against a peer sibling and tags each invariant Mirrored / Missing / Diverged / Intentionally-absent |
|
|
194
200
|
| `precedent-locator` | Finds similar past changes in git history - commits, blast radius, and follow-up fixes |
|
|
201
|
+
| `scope-tracer` | Sweeps anchor terms and reads key files to bound a research investigation - returns a Discovery Summary plus dense numbered questions |
|
|
202
|
+
| `slice-verifier` | Adversarially audits each freshly-generated slice of a phased plan or design before it is locked - catches forward-references, cross-slice symbol mismatches, and decision drift |
|
|
203
|
+
| `artifact-code-reviewer` | Reviews each slice code fence in a finalized artifact for code quality, codebase fit, and actionability - one severity-tagged row per finding |
|
|
204
|
+
| `artifact-coverage-reviewer` | Verifies every Verification Note and Precedent entry in a finalized artifact lands somewhere actionable - success criterion or emitted code |
|
|
195
205
|
| `artifacts-analyzer` | Performs deep-dive analysis on a research topic in `.rpiv/artifacts/` |
|
|
196
206
|
| `artifacts-locator` | Discovers relevant documents in the `.rpiv/artifacts/` directory |
|
|
197
207
|
| `web-search-researcher` | Researches modern web-only information via deep search and fetch |
|
|
@@ -210,7 +220,7 @@ Pi Agent discovers extensions via `"extensions": ["./extensions"]` and skills vi
|
|
|
210
220
|
|
|
211
221
|
## Configuration
|
|
212
222
|
|
|
213
|
-
- **Web search** - run `/web-
|
|
223
|
+
- **Web search** - run `/web-tools` to pick a provider (Brave, Tavily, Serper, Exa, You.com, Jina, Firecrawl, Perplexity, SearXNG, or Ollama) and set its API key; the per-provider env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) also works and takes precedence
|
|
214
224
|
- **Advisor** - run `/advisor` to select a reviewer model and reasoning effort
|
|
215
225
|
- **Models & reasoning effort** - run `/rpiv-models` to pick a model and reasoning level for the global default, a specific bundled agent, a workflow stage, a skill, or a per-preset stage; the picker writes `~/.config/rpiv-pi/models.json`. See **Model configuration** below for the cascade ladder and worked examples.
|
|
216
226
|
- **Side questions** _(opt-in: `pi install npm:@juicesharp/rpiv-btw`)_ - type `/btw <question>` anytime (even mid-stream) to ask the primary model a one-off question; answer appears in a borderless bottom overlay and never enters the main conversation
|
|
@@ -287,7 +297,7 @@ With this file, `/wf ship plan` and `/wf ship design` use GPT-5.5; `/wf polish p
|
|
|
287
297
|
| Warning about missing siblings on session start | Sibling plugins not installed | Run `/rpiv-setup` |
|
|
288
298
|
| `/rpiv-setup` fails on a package | Network or registry issue | Check connection, retry with `pi install npm:<pkg>`, re-run `/rpiv-setup` |
|
|
289
299
|
| `/rpiv-setup` says "requires interactive mode" | Running in headless mode | Install manually: `pi install npm:<pkg>` for each sibling |
|
|
290
|
-
| `web_search` or `web_fetch` errors | Active provider's API key not configured | Run `/web-
|
|
300
|
+
| `web_search` or `web_fetch` errors | Active provider's API key not configured | Run `/web-tools` or set the matching env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) |
|
|
291
301
|
| `advisor` tool not available after upgrade | Advisor model selection lost | Run `/advisor` to re-select a model |
|
|
292
302
|
| Skills hang or serialize agent calls | Agent concurrency too low | Open `/agents`, raise `Settings → Max concurrency` |
|
|
293
303
|
|
|
@@ -33,17 +33,15 @@ const bundledContent = (name: string) => readFileSync(join(BUNDLED_AGENTS_DIR, n
|
|
|
33
33
|
let cwd: string;
|
|
34
34
|
let targetDir: string;
|
|
35
35
|
let manifestPath: string;
|
|
36
|
-
let markerPath: string;
|
|
37
36
|
|
|
38
37
|
beforeEach(() => {
|
|
39
38
|
cwd = mkdtempSync(join(tmpdir(), "rpiv-agents-"));
|
|
40
39
|
targetDir = join(homedir(), ".pi", "agent", "agents");
|
|
41
40
|
manifestPath = join(targetDir, ".rpiv-managed.json");
|
|
42
|
-
markerPath = join(targetDir, ".rpiv-managed.v2");
|
|
43
41
|
});
|
|
44
42
|
afterEach(() => {
|
|
45
43
|
rmSync(cwd, { recursive: true, force: true });
|
|
46
|
-
// Remove the `agent/` parent — not just `agent/agents/` — so
|
|
44
|
+
// Remove the `agent/` parent — not just `agent/agents/` — so writeFileSync
|
|
47
45
|
// (which needs the `agent` slot empty) and cross-test isolation both hold.
|
|
48
46
|
rmSync(join(homedir(), ".pi", "agent"), { recursive: true, force: true });
|
|
49
47
|
vi.restoreAllMocks();
|
|
@@ -54,7 +52,7 @@ afterEach(() => {
|
|
|
54
52
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
53
|
|
|
56
54
|
describe("syncBundledAgents — first-run (no manifest, empty target)", () => {
|
|
57
|
-
it("copies every source .md and writes a
|
|
55
|
+
it("copies every source .md and writes a manifest with sha256 hashes", () => {
|
|
58
56
|
const r = syncBundledAgents(false);
|
|
59
57
|
const bundled = bundledNames();
|
|
60
58
|
expect(r.added.sort()).toEqual(bundled.sort());
|
|
@@ -71,145 +69,10 @@ describe("syncBundledAgents — first-run (no manifest, empty target)", () => {
|
|
|
71
69
|
expect(manifest[name]).toMatch(/^[a-f0-9]{64}$/);
|
|
72
70
|
}
|
|
73
71
|
});
|
|
74
|
-
|
|
75
|
-
it("writes the .rpiv-managed.v2 sentinel marker after first successful sync", () => {
|
|
76
|
-
syncBundledAgents(false);
|
|
77
|
-
expect(existsSync(markerPath)).toBe(true);
|
|
78
|
-
expect(readFileSync(markerPath, "utf-8")).toBe("");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
-
// Legacy v1 manifest one-shot migration (package wins on conflict)
|
|
84
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
describe("syncBundledAgents — legacy v1 manifest one-shot migration", () => {
|
|
87
|
-
it("silently records hash when dest already matches src (no overwrite)", () => {
|
|
88
|
-
const bundled = bundledNames();
|
|
89
|
-
if (bundled.length === 0) return;
|
|
90
|
-
mkdirSync(targetDir, { recursive: true });
|
|
91
|
-
const target = bundled[0];
|
|
92
|
-
writeFileSync(join(targetDir, target), bundledContent(target), "utf-8");
|
|
93
|
-
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
94
|
-
|
|
95
|
-
const r = syncBundledAgents(false);
|
|
96
|
-
|
|
97
|
-
expect(r.unchanged).toContain(target);
|
|
98
|
-
expect(r.updated).not.toContain(target);
|
|
99
|
-
expect(r.pendingUpdate).not.toContain(target);
|
|
100
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
101
|
-
expect(manifest[target]).toBe(sha256(bundledContent(target)));
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("overwrites a user-edited bundled agent (package wins)", () => {
|
|
105
|
-
const bundled = bundledNames();
|
|
106
|
-
if (bundled.length === 0) return;
|
|
107
|
-
mkdirSync(targetDir, { recursive: true });
|
|
108
|
-
const target = bundled[0];
|
|
109
|
-
writeFileSync(join(targetDir, target), "user-edited content", "utf-8");
|
|
110
|
-
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
111
|
-
|
|
112
|
-
const r = syncBundledAgents(false);
|
|
113
|
-
|
|
114
|
-
expect(r.updated).toContain(target);
|
|
115
|
-
expect(r.pendingUpdate).not.toContain(target);
|
|
116
|
-
expect(readFileSync(join(targetDir, target), "utf-8")).toBe(bundledContent(target));
|
|
117
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
118
|
-
expect(manifest[target]).toBe(sha256(bundledContent(target)));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("copies a bundled agent missing from disk (legacy install pre-dating the agent)", () => {
|
|
122
|
-
const bundled = bundledNames();
|
|
123
|
-
if (bundled.length === 0) return;
|
|
124
|
-
mkdirSync(targetDir, { recursive: true });
|
|
125
|
-
// Legacy manifest knows the OTHER agents; this one is "new" since their install
|
|
126
|
-
const target = bundled[0];
|
|
127
|
-
const legacyEntries = bundled.slice(1);
|
|
128
|
-
for (const name of legacyEntries) writeFileSync(join(targetDir, name), bundledContent(name), "utf-8");
|
|
129
|
-
writeFileSync(manifestPath, JSON.stringify(legacyEntries), "utf-8");
|
|
130
|
-
|
|
131
|
-
const r = syncBundledAgents(false);
|
|
132
|
-
|
|
133
|
-
expect(r.added).toContain(target);
|
|
134
|
-
expect(existsSync(join(targetDir, target))).toBe(true);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("removes stale entries (in v1 manifest, no longer in source) when dest is unchanged", () => {
|
|
138
|
-
mkdirSync(targetDir, { recursive: true });
|
|
139
|
-
writeFileSync(join(targetDir, "stale.md"), "old shipped content", "utf-8");
|
|
140
|
-
writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
|
|
141
|
-
|
|
142
|
-
const r = syncBundledAgents(false);
|
|
143
|
-
|
|
144
|
-
expect(r.removed).toContain("stale.md");
|
|
145
|
-
expect(r.pendingRemove).not.toContain("stale.md");
|
|
146
|
-
expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("removes stale entries even when dest was user-edited (legacy: no record to protect)", () => {
|
|
150
|
-
mkdirSync(targetDir, { recursive: true });
|
|
151
|
-
writeFileSync(join(targetDir, "stale.md"), "user-edited stale content", "utf-8");
|
|
152
|
-
writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
|
|
153
|
-
|
|
154
|
-
const r = syncBundledAgents(false);
|
|
155
|
-
|
|
156
|
-
expect(r.removed).toContain("stale.md");
|
|
157
|
-
expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("filters non-string entries from a v1 manifest and still removes valid stale ones", () => {
|
|
161
|
-
mkdirSync(targetDir, { recursive: true });
|
|
162
|
-
writeFileSync(join(targetDir, "unrelated.md"), "stale", "utf-8");
|
|
163
|
-
writeFileSync(manifestPath, JSON.stringify([42, null, "unrelated.md"]), "utf-8");
|
|
164
|
-
|
|
165
|
-
const r = syncBundledAgents(false);
|
|
166
|
-
|
|
167
|
-
expect(r.errors).toEqual([]);
|
|
168
|
-
expect(r.removed).toContain("unrelated.md");
|
|
169
|
-
expect(existsSync(join(targetDir, "unrelated.md"))).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("rewrites manifest as v2 with real hashes for every kept entry after migration", () => {
|
|
173
|
-
mkdirSync(targetDir, { recursive: true });
|
|
174
|
-
const bundled = bundledNames();
|
|
175
|
-
for (const name of bundled) writeFileSync(join(targetDir, name), "stale legacy content", "utf-8");
|
|
176
|
-
writeFileSync(manifestPath, JSON.stringify(bundled), "utf-8");
|
|
177
|
-
|
|
178
|
-
syncBundledAgents(false);
|
|
179
|
-
|
|
180
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
181
|
-
expect(Array.isArray(manifest)).toBe(false);
|
|
182
|
-
for (const name of bundled) {
|
|
183
|
-
expect(manifest[name]).toBe(sha256(readFileSync(join(BUNDLED_AGENTS_DIR, name))));
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("respects user edits on the SECOND session_start (post-migration v2 gate active)", () => {
|
|
188
|
-
const bundled = bundledNames();
|
|
189
|
-
if (bundled.length === 0) return;
|
|
190
|
-
const target = bundled[0];
|
|
191
|
-
|
|
192
|
-
// Legacy migration: any disk content gets overwritten to canonical
|
|
193
|
-
mkdirSync(targetDir, { recursive: true });
|
|
194
|
-
writeFileSync(join(targetDir, target), "pre-migration drift", "utf-8");
|
|
195
|
-
writeFileSync(manifestPath, JSON.stringify([target]), "utf-8");
|
|
196
|
-
syncBundledAgents(false);
|
|
197
|
-
// First successful sync commits the v2 marker (one-shot per project).
|
|
198
|
-
expect(existsSync(markerPath)).toBe(true);
|
|
199
|
-
|
|
200
|
-
// User edits AFTER migration
|
|
201
|
-
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
202
|
-
|
|
203
|
-
const r2 = syncBundledAgents(false);
|
|
204
|
-
|
|
205
|
-
expect(r2.pendingUpdate).toContain(target);
|
|
206
|
-
expect(r2.updated).not.toContain(target);
|
|
207
|
-
expect(readFileSync(join(targetDir, target), "utf-8")).toBe("user customization");
|
|
208
|
-
});
|
|
209
72
|
});
|
|
210
73
|
|
|
211
74
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
-
// Missing / corrupt manifest (
|
|
75
|
+
// Missing / corrupt manifest (no recorded hashes → drift is gated)
|
|
213
76
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
77
|
|
|
215
78
|
describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
@@ -228,7 +91,7 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
228
91
|
expect(manifest[target]).toBe(sha256(bundledContent(target)));
|
|
229
92
|
});
|
|
230
93
|
|
|
231
|
-
it("first run with no manifest and drift on disk
|
|
94
|
+
it("first run with no manifest and drift on disk gates the file (no baseline, no clobber)", () => {
|
|
232
95
|
const bundled = bundledNames();
|
|
233
96
|
if (bundled.length === 0) return;
|
|
234
97
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -237,30 +100,28 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
237
100
|
|
|
238
101
|
const r = syncBundledAgents(false);
|
|
239
102
|
|
|
240
|
-
expect(r.
|
|
241
|
-
expect(
|
|
103
|
+
expect(r.pendingUpdate).toContain(target);
|
|
104
|
+
expect(r.updated).not.toContain(target);
|
|
105
|
+
expect(readFileSync(join(targetDir, target), "utf-8")).toBe("drift content");
|
|
242
106
|
});
|
|
243
107
|
|
|
244
|
-
it("treats a corrupt JSON manifest
|
|
108
|
+
it("treats a corrupt JSON manifest as missing (drift gated, manifest rewritten as an object)", () => {
|
|
245
109
|
const bundled = bundledNames();
|
|
246
110
|
if (bundled.length === 0) return;
|
|
247
111
|
mkdirSync(targetDir, { recursive: true });
|
|
248
112
|
writeFileSync(manifestPath, "{ not json ::", "utf-8");
|
|
249
113
|
writeFileSync(join(targetDir, bundled[0]), "drift", "utf-8");
|
|
250
|
-
expect(existsSync(markerPath)).toBe(false);
|
|
251
114
|
|
|
252
115
|
const r = syncBundledAgents(false);
|
|
253
116
|
|
|
254
117
|
expect(r.errors).toEqual([]);
|
|
255
|
-
expect(r.
|
|
118
|
+
expect(r.pendingUpdate).toContain(bundled[0]);
|
|
256
119
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
257
120
|
expect(typeof manifest).toBe("object");
|
|
258
121
|
expect(Array.isArray(manifest)).toBe(false);
|
|
259
|
-
// Marker committed after a successful manifest write closes the legacy window.
|
|
260
|
-
expect(existsSync(markerPath)).toBe(true);
|
|
261
122
|
});
|
|
262
123
|
|
|
263
|
-
it("treats a non-
|
|
124
|
+
it("treats a non-object manifest (e.g. number) as missing", () => {
|
|
264
125
|
const bundled = bundledNames();
|
|
265
126
|
if (bundled.length === 0) return;
|
|
266
127
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -270,15 +131,15 @@ describe("syncBundledAgents — missing/corrupt manifest", () => {
|
|
|
270
131
|
const r = syncBundledAgents(false);
|
|
271
132
|
|
|
272
133
|
expect(r.errors).toEqual([]);
|
|
273
|
-
expect(r.
|
|
134
|
+
expect(r.pendingUpdate).toContain(bundled[0]);
|
|
274
135
|
});
|
|
275
136
|
});
|
|
276
137
|
|
|
277
138
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
278
|
-
//
|
|
139
|
+
// Manifest smart gate (steady state)
|
|
279
140
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
280
141
|
|
|
281
|
-
describe("syncBundledAgents —
|
|
142
|
+
describe("syncBundledAgents — manifest smart gate (apply=false)", () => {
|
|
282
143
|
it("auto-updates when dest content matches recorded hash", () => {
|
|
283
144
|
const bundled = bundledNames();
|
|
284
145
|
if (bundled.length === 0) return;
|
|
@@ -287,7 +148,7 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
287
148
|
|
|
288
149
|
const oldContent = "old version we previously installed";
|
|
289
150
|
writeFileSync(join(targetDir, target), oldContent, "utf-8");
|
|
290
|
-
//
|
|
151
|
+
// Recorded hash matches what we just wrote, so "user hasn't edited"
|
|
291
152
|
writeFileSync(manifestPath, JSON.stringify({ [target]: sha256(oldContent) }), "utf-8");
|
|
292
153
|
|
|
293
154
|
const r = syncBundledAgents(false);
|
|
@@ -360,10 +221,10 @@ describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
|
|
|
360
221
|
});
|
|
361
222
|
|
|
362
223
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
-
// Custom user agents
|
|
224
|
+
// Custom user agents
|
|
364
225
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
365
226
|
|
|
366
|
-
describe("syncBundledAgents — custom user agents
|
|
227
|
+
describe("syncBundledAgents — custom user agents", () => {
|
|
367
228
|
it("ignores a custom user .md whose name does NOT match any bundled agent", () => {
|
|
368
229
|
syncBundledAgents(true);
|
|
369
230
|
const customPath = join(targetDir, "my-custom-agent.md");
|
|
@@ -414,14 +275,12 @@ describe("syncBundledAgents — custom user agents (v2 manifest active)", () =>
|
|
|
414
275
|
partial[name] = sha256(bundledContent(name));
|
|
415
276
|
}
|
|
416
277
|
writeFileSync(manifestPath, JSON.stringify(partial), "utf-8");
|
|
417
|
-
// V2 marker is what gates the project as v2-active (no longer manifest content)
|
|
418
|
-
writeFileSync(markerPath, "", "utf-8");
|
|
419
278
|
// User's hand-placed file diverges from canonical
|
|
420
279
|
writeFileSync(join(targetDir, target), "user wrote this", "utf-8");
|
|
421
280
|
|
|
422
281
|
const r = syncBundledAgents(false);
|
|
423
282
|
|
|
424
|
-
//
|
|
283
|
+
// No recorded hash for the hand-placed file → gated
|
|
425
284
|
expect(r.pendingUpdate).toContain(target);
|
|
426
285
|
expect(r.updated).not.toContain(target);
|
|
427
286
|
expect(readFileSync(join(targetDir, target), "utf-8")).toBe("user wrote this");
|
|
@@ -433,7 +292,7 @@ describe("syncBundledAgents — custom user agents (v2 manifest active)", () =>
|
|
|
433
292
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
434
293
|
|
|
435
294
|
describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
436
|
-
it("overwrites a user-edited file even
|
|
295
|
+
it("overwrites a user-edited file even when the smart gate would block it", () => {
|
|
437
296
|
const bundled = bundledNames();
|
|
438
297
|
if (bundled.length === 0) return;
|
|
439
298
|
syncBundledAgents(true);
|
|
@@ -457,22 +316,6 @@ describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
|
457
316
|
expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
|
|
458
317
|
});
|
|
459
318
|
|
|
460
|
-
it("baselines real hashes from a v1 manifest (after-upgrade sync)", () => {
|
|
461
|
-
syncBundledAgents(true);
|
|
462
|
-
const bundled = bundledNames();
|
|
463
|
-
if (bundled.length === 0) return;
|
|
464
|
-
// Roll back manifest to v1 form to simulate upgrade-then-/rpiv-update-agents
|
|
465
|
-
writeFileSync(manifestPath, JSON.stringify(bundled), "utf-8");
|
|
466
|
-
|
|
467
|
-
syncBundledAgents(true);
|
|
468
|
-
|
|
469
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
470
|
-
expect(Array.isArray(manifest)).toBe(false);
|
|
471
|
-
for (const name of bundled) {
|
|
472
|
-
expect(manifest[name]).toBe(sha256(readFileSync(join(BUNDLED_AGENTS_DIR, name))));
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
|
|
476
319
|
it("leaves unchanged files alone", () => {
|
|
477
320
|
syncBundledAgents(true);
|
|
478
321
|
const r = syncBundledAgents(true);
|
|
@@ -486,7 +329,7 @@ describe("syncBundledAgents — apply=true (forced sync)", () => {
|
|
|
486
329
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
487
330
|
|
|
488
331
|
describe("syncBundledAgents — manifest robustness", () => {
|
|
489
|
-
it("filters non-string values from
|
|
332
|
+
it("filters non-string values from the manifest object", () => {
|
|
490
333
|
const bundled = bundledNames();
|
|
491
334
|
if (bundled.length === 0) return;
|
|
492
335
|
mkdirSync(targetDir, { recursive: true });
|
|
@@ -508,14 +351,12 @@ describe("syncBundledAgents — manifest robustness", () => {
|
|
|
508
351
|
expect(manifest.badNull).toBeUndefined();
|
|
509
352
|
});
|
|
510
353
|
|
|
511
|
-
it("
|
|
354
|
+
it("gates entries whose recorded hash is empty (unknown baseline)", () => {
|
|
512
355
|
const bundled = bundledNames();
|
|
513
356
|
if (bundled.length < 2) return;
|
|
514
357
|
mkdirSync(targetDir, { recursive: true });
|
|
515
358
|
const [a, b] = bundled;
|
|
516
359
|
writeFileSync(manifestPath, JSON.stringify({ [a]: sha256(bundledContent(a)), [b]: "" }), "utf-8");
|
|
517
|
-
// Marker is what gates v2 — not the hash content.
|
|
518
|
-
writeFileSync(markerPath, "", "utf-8");
|
|
519
360
|
writeFileSync(join(targetDir, a), bundledContent(a), "utf-8");
|
|
520
361
|
writeFileSync(join(targetDir, b), "user-edited content", "utf-8");
|
|
521
362
|
|
|
@@ -527,70 +368,11 @@ describe("syncBundledAgents — manifest robustness", () => {
|
|
|
527
368
|
});
|
|
528
369
|
});
|
|
529
370
|
|
|
530
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
-
// I1 — V2 sentinel marker survives manifest content corruption
|
|
532
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
-
|
|
534
|
-
describe("syncBundledAgents — I1: v2 sentinel marker", () => {
|
|
535
|
-
it("with marker present and corrupt JSON manifest, gates user edits as pendingUpdate", () => {
|
|
536
|
-
const bundled = bundledNames();
|
|
537
|
-
if (bundled.length === 0) return;
|
|
538
|
-
syncBundledAgents(false); // first sync — writes marker
|
|
539
|
-
expect(existsSync(markerPath)).toBe(true);
|
|
540
|
-
|
|
541
|
-
const target = bundled[0];
|
|
542
|
-
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
543
|
-
writeFileSync(manifestPath, "{ corrupt :: not json", "utf-8");
|
|
544
|
-
|
|
545
|
-
const r = syncBundledAgents(false);
|
|
546
|
-
|
|
547
|
-
expect(r.updated).not.toContain(target);
|
|
548
|
-
expect(r.pendingUpdate).toContain(target);
|
|
549
|
-
expect(readFileSync(join(targetDir, target), "utf-8")).toBe("user customization");
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("with marker present and all-empty-hash manifest, gates user edits as pendingUpdate", () => {
|
|
553
|
-
const bundled = bundledNames();
|
|
554
|
-
if (bundled.length === 0) return;
|
|
555
|
-
syncBundledAgents(false);
|
|
556
|
-
|
|
557
|
-
const target = bundled[0];
|
|
558
|
-
writeFileSync(join(targetDir, target), "user customization", "utf-8");
|
|
559
|
-
const empty: Record<string, string> = {};
|
|
560
|
-
for (const name of bundled) empty[name] = "";
|
|
561
|
-
writeFileSync(manifestPath, JSON.stringify(empty), "utf-8");
|
|
562
|
-
|
|
563
|
-
const r = syncBundledAgents(false);
|
|
564
|
-
|
|
565
|
-
expect(r.updated).not.toContain(target);
|
|
566
|
-
expect(r.pendingUpdate).toContain(target);
|
|
567
|
-
expect(readFileSync(join(targetDir, target), "utf-8")).toBe("user customization");
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it("with marker absent (truly fresh / pre-migration), legacy branch fires once", () => {
|
|
571
|
-
const bundled = bundledNames();
|
|
572
|
-
if (bundled.length === 0) return;
|
|
573
|
-
mkdirSync(targetDir, { recursive: true });
|
|
574
|
-
const target = bundled[0];
|
|
575
|
-
writeFileSync(join(targetDir, target), "drift", "utf-8");
|
|
576
|
-
expect(existsSync(markerPath)).toBe(false);
|
|
577
|
-
|
|
578
|
-
const r1 = syncBundledAgents(false);
|
|
579
|
-
expect(r1.updated).toContain(target);
|
|
580
|
-
expect(existsSync(markerPath)).toBe(true);
|
|
581
|
-
|
|
582
|
-
writeFileSync(join(targetDir, target), "post-migration user edit", "utf-8");
|
|
583
|
-
const r2 = syncBundledAgents(false);
|
|
584
|
-
expect(r2.updated).not.toContain(target);
|
|
585
|
-
expect(r2.pendingUpdate).toContain(target);
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
|
|
589
371
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
590
372
|
// Error paths
|
|
591
373
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
592
374
|
|
|
593
|
-
describe("syncBundledAgents —
|
|
375
|
+
describe("syncBundledAgents — path-traversal hardening", () => {
|
|
594
376
|
it("ignores manifest keys with `..` segments (no unlink, no read)", () => {
|
|
595
377
|
mkdirSync(targetDir, { recursive: true });
|
|
596
378
|
const sentinel = join(cwd, "sentinel.md");
|
|
@@ -627,18 +409,6 @@ describe("syncBundledAgents — I2: path-traversal hardening", () => {
|
|
|
627
409
|
expect(existsSync(join(targetDir, "weird.txt"))).toBe(true);
|
|
628
410
|
});
|
|
629
411
|
|
|
630
|
-
it("ignores v1-array entries with traversal segments", () => {
|
|
631
|
-
mkdirSync(targetDir, { recursive: true });
|
|
632
|
-
const sentinel = join(cwd, "v1-sentinel.md");
|
|
633
|
-
writeFileSync(sentinel, "v1 target", "utf-8");
|
|
634
|
-
writeFileSync(manifestPath, JSON.stringify(["../../v1-sentinel.md"]), "utf-8");
|
|
635
|
-
|
|
636
|
-
const r = syncBundledAgents(false);
|
|
637
|
-
|
|
638
|
-
expect(existsSync(sentinel)).toBe(true);
|
|
639
|
-
expect(r.removed).not.toContain("../../v1-sentinel.md");
|
|
640
|
-
});
|
|
641
|
-
|
|
642
412
|
it("ignores manifest keys containing a NUL byte", () => {
|
|
643
413
|
mkdirSync(targetDir, { recursive: true });
|
|
644
414
|
const nulKey = `evil${String.fromCharCode(0)}.md`;
|
|
@@ -666,9 +436,8 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
666
436
|
}
|
|
667
437
|
});
|
|
668
438
|
|
|
669
|
-
it("does not throw when manifest claims a stale file that disappeared from disk
|
|
670
|
-
//
|
|
671
|
-
// This test exercises the legacy/no-marker branch; Q5 below covers the v2-marker branch.
|
|
439
|
+
it("does not throw when manifest claims a stale file that disappeared from disk", () => {
|
|
440
|
+
// Contract: vanished tracked files surface as result.removed (not silently dropped).
|
|
672
441
|
mkdirSync(targetDir, { recursive: true });
|
|
673
442
|
writeFileSync(manifestPath, JSON.stringify({ "stale.md": sha256("x") }), "utf-8");
|
|
674
443
|
|
|
@@ -681,8 +450,8 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
681
450
|
expect(Object.keys(manifest)).not.toContain("stale.md");
|
|
682
451
|
});
|
|
683
452
|
|
|
684
|
-
it.skipIf(process.platform === "win32")("
|
|
685
|
-
//
|
|
453
|
+
it.skipIf(process.platform === "win32")("writeManifest failure surfaces op:'manifest-write' SyncError", () => {
|
|
454
|
+
// Make the targetDir read-only so writeFileSync(tmpFile) fails (not just renameSync).
|
|
686
455
|
// Atomic write writes a NEW .tmp file then renames — chmod-ing the manifest file
|
|
687
456
|
// is insufficient because renameSync only needs directory write perms.
|
|
688
457
|
syncBundledAgents(true);
|
|
@@ -699,7 +468,7 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
699
468
|
}
|
|
700
469
|
});
|
|
701
470
|
|
|
702
|
-
it("
|
|
471
|
+
it("mkdir failure tagged op:'mkdir' (not op:'manifest-write')", () => {
|
|
703
472
|
// Reset pre-state: parent must exist as a dir; the agent slot must NOT exist
|
|
704
473
|
// (other tests may have left it as an empty dir via mkdirSync(recursive)).
|
|
705
474
|
mkdirSync(join(homedir(), ".pi"), { recursive: true });
|
|
@@ -717,25 +486,11 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
717
486
|
}
|
|
718
487
|
});
|
|
719
488
|
|
|
720
|
-
it("Q5: pushes to result.removed when a tracked file has already vanished from disk (v2 active)", () => {
|
|
721
|
-
mkdirSync(targetDir, { recursive: true });
|
|
722
|
-
writeFileSync(manifestPath, JSON.stringify({ "stale.md": sha256("x") }), "utf-8");
|
|
723
|
-
writeFileSync(markerPath, "", "utf-8");
|
|
724
|
-
// Note: 'stale.md' is NOT created on disk — it has vanished while still tracked.
|
|
725
|
-
|
|
726
|
-
const r = syncBundledAgents(false);
|
|
727
|
-
|
|
728
|
-
expect(r.removed).toContain("stale.md");
|
|
729
|
-
expect(r.errors).toEqual([]);
|
|
730
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
731
|
-
expect(Object.keys(manifest)).not.toContain("stale.md");
|
|
732
|
-
});
|
|
733
|
-
|
|
734
489
|
it.skipIf(process.platform === "win32")(
|
|
735
|
-
"
|
|
490
|
+
"read-src failure preserves prior knownHash and reports op:'read-src'",
|
|
736
491
|
() => {
|
|
737
|
-
//
|
|
738
|
-
// not configurable under this Vitest config
|
|
492
|
+
// vi.spyOn(fs, "readFileSync") won't work here: ESM module namespaces are
|
|
493
|
+
// not configurable under this Vitest config. Inject
|
|
739
494
|
// the failure by chmod-ing one bundled-agent source file to 0o000 so that
|
|
740
495
|
// readFileSync(src) throws EACCES; restore in finally.
|
|
741
496
|
const bundled = bundledNames();
|
|
@@ -761,12 +516,11 @@ describe("syncBundledAgents — error paths", () => {
|
|
|
761
516
|
},
|
|
762
517
|
);
|
|
763
518
|
|
|
764
|
-
it.skipIf(process.platform === "win32")("
|
|
519
|
+
it.skipIf(process.platform === "win32")("read-dest catch on stale-loop emits op:'read-dest'", () => {
|
|
765
520
|
mkdirSync(targetDir, { recursive: true });
|
|
766
521
|
const stalePath = join(targetDir, "stale.md");
|
|
767
522
|
writeFileSync(stalePath, "managed content", "utf-8");
|
|
768
523
|
writeFileSync(manifestPath, JSON.stringify({ "stale.md": sha256("managed content") }), "utf-8");
|
|
769
|
-
writeFileSync(markerPath, "", "utf-8");
|
|
770
524
|
chmodSync(stalePath, 0o000);
|
|
771
525
|
|
|
772
526
|
try {
|
|
@@ -888,38 +642,29 @@ describe("cleanupPerCwdAgents — conservative all-or-nothing cleanup", () => {
|
|
|
888
642
|
|
|
889
643
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
890
644
|
// Unified safety predicate — exercised indirectly through syncBundledAgents,
|
|
891
|
-
// pinned here directly so the
|
|
645
|
+
// pinned here directly so the branches stay regression-checked.
|
|
892
646
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
893
647
|
|
|
894
648
|
describe("isSafeDestructiveOp", () => {
|
|
895
649
|
const HASH_A = "a".repeat(64);
|
|
896
650
|
const HASH_B = "b".repeat(64);
|
|
897
651
|
|
|
898
|
-
it("
|
|
899
|
-
expect(isSafeDestructiveOp({
|
|
900
|
-
expect(isSafeDestructiveOp({ hasV2Data: false, knownHash: HASH_A, destHash: HASH_A })).toBe(true);
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
it("safeLegacy: no v2 marker AND empty known hash → true (pre-migration, package wins)", () => {
|
|
904
|
-
expect(isSafeDestructiveOp({ hasV2Data: false, knownHash: "", destHash: HASH_A })).toBe(true);
|
|
905
|
-
expect(isSafeDestructiveOp({ hasV2Data: false, knownHash: "", destHash: "" })).toBe(true);
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
it("rejects: v2 marker present + known hash differs from dest → false (user edited)", () => {
|
|
909
|
-
expect(isSafeDestructiveOp({ hasV2Data: true, knownHash: HASH_A, destHash: HASH_B })).toBe(false);
|
|
652
|
+
it("accepts when the recorded hash matches dest (user hasn't edited)", () => {
|
|
653
|
+
expect(isSafeDestructiveOp({ knownHash: HASH_A, destHash: HASH_A })).toBe(true);
|
|
910
654
|
});
|
|
911
655
|
|
|
912
|
-
it("rejects
|
|
913
|
-
expect(isSafeDestructiveOp({
|
|
656
|
+
it("rejects when dest differs from the recorded hash (user edited)", () => {
|
|
657
|
+
expect(isSafeDestructiveOp({ knownHash: HASH_A, destHash: HASH_B })).toBe(false);
|
|
914
658
|
});
|
|
915
659
|
|
|
916
|
-
it("rejects
|
|
917
|
-
expect(isSafeDestructiveOp({
|
|
660
|
+
it("rejects when no hash was recorded (no baseline, no consent)", () => {
|
|
661
|
+
expect(isSafeDestructiveOp({ knownHash: "", destHash: HASH_A })).toBe(false);
|
|
662
|
+
expect(isSafeDestructiveOp({ knownHash: "", destHash: "" })).toBe(false);
|
|
918
663
|
});
|
|
919
664
|
});
|
|
920
665
|
|
|
921
666
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
922
|
-
// Agent frontmatter injection
|
|
667
|
+
// Agent frontmatter injection
|
|
923
668
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
924
669
|
|
|
925
670
|
describe("agent frontmatter injection", () => {
|