@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 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 8 more, dispatched automatically by skills.
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-search-config
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-search-config` | Pick the active search provider and set its API key |
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-search-config` to pick a provider (Brave, Tavily, Serper, Exa, Jina, or Firecrawl) and set its API key; the per-provider env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) also works and takes precedence
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-search-config` or set the matching env var (e.g. `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`) |
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 Q18's writeFileSync
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 v2 manifest with sha256 hashes", () => {
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 (treated as legacy-equivalent: package wins)
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 overwrites to package version", () => {
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.updated).toContain(target);
241
- expect(readFileSync(join(targetDir, target), "utf-8")).toBe(bundledContent(target));
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 with NO marker as missing (package wins, manifest rewritten as v2)", () => {
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.updated).toContain(bundled[0]);
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-array, non-object manifest (e.g. number) as missing", () => {
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.updated).toContain(bundled[0]);
134
+ expect(r.pendingUpdate).toContain(bundled[0]);
274
135
  });
275
136
  });
276
137
 
277
138
  // ─────────────────────────────────────────────────────────────────────────────
278
- // v2 manifest smart gate (post-migration steady state)
139
+ // Manifest smart gate (steady state)
279
140
  // ─────────────────────────────────────────────────────────────────────────────
280
141
 
281
- describe("syncBundledAgents — v2 manifest smart gate (apply=false)", () => {
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
- // v2 manifest: hash matches what we just wrote, so "user hasn't edited"
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 (with v2 manifest active)
224
+ // Custom user agents
364
225
  // ─────────────────────────────────────────────────────────────────────────────
365
226
 
366
- describe("syncBundledAgents — custom user agents (v2 manifest active)", () => {
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
- // hasV2Data=true (marker present) unknown entry is gated
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 with v2 gate in place", () => {
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 a v2 object manifest", () => {
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("treats a partly-empty v2 manifest WITH MARKER as 'v2 active' and gates the empty entries", () => {
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 — I2: path-traversal hardening", () => {
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 (legacy mode)", () => {
670
- // Q5 contract: vanished tracked files surface as result.removed (not silently dropped).
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")("Q9: writeManifest failure surfaces op:'manifest-write' SyncError", () => {
685
- // Q9: make the targetDir read-only so writeFileSync(tmpFile) fails (not just renameSync).
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("Q18: mkdir failure tagged op:'mkdir' (not op:'manifest-write')", () => {
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
- "Q7: read-src failure preserves prior knownHash and reports op:'read-src'",
490
+ "read-src failure preserves prior knownHash and reports op:'read-src'",
736
491
  () => {
737
- // Plan called for vi.spyOn(fs, "readFileSync"), but ESM module namespaces are
738
- // not configurable under this Vitest config (same constraint as Q18). Inject
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")("Q8: read-dest catch on stale-loop emits op:'read-dest'", () => {
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 three branches stay regression-checked.
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("safeSmart: known hash matches dest true regardless of v2 marker", () => {
899
- expect(isSafeDestructiveOp({ hasV2Data: true, knownHash: HASH_A, destHash: HASH_A })).toBe(true);
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: v2 marker present + empty known hash → false (no baseline, no consent)", () => {
913
- expect(isSafeDestructiveOp({ hasV2Data: true, knownHash: "", destHash: HASH_A })).toBe(false);
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: v2 marker absent + known hash differs from dest → false (smart gate trumps legacy)", () => {
917
- expect(isSafeDestructiveOp({ hasV2Data: false, knownHash: HASH_A, destHash: HASH_B })).toBe(false);
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 (Phase 2)
667
+ // Agent frontmatter injection
923
668
  // ─────────────────────────────────────────────────────────────────────────────
924
669
 
925
670
  describe("agent frontmatter injection", () => {