@juicesharp/rpiv-pi 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +60 -2
  2. package/agents/slice-verifier.md +4 -1
  3. package/agents/web-search-researcher.md +2 -1
  4. package/extensions/rpiv-core/agents.test.ts +120 -0
  5. package/extensions/rpiv-core/agents.ts +97 -6
  6. package/extensions/rpiv-core/artifact-collector.ts +2 -1
  7. package/extensions/rpiv-core/built-in-workflows.ts +1 -1
  8. package/extensions/rpiv-core/frontmatter.test.ts +51 -0
  9. package/extensions/rpiv-core/frontmatter.ts +32 -0
  10. package/extensions/rpiv-core/index.ts +37 -8
  11. package/extensions/rpiv-core/model-override.test.ts +559 -0
  12. package/extensions/rpiv-core/model-override.ts +328 -0
  13. package/extensions/rpiv-core/models-config-sources.ts +51 -0
  14. package/extensions/rpiv-core/models-config-validate.test.ts +131 -0
  15. package/extensions/rpiv-core/models-config-validate.ts +70 -0
  16. package/extensions/rpiv-core/models-config.test.ts +461 -0
  17. package/extensions/rpiv-core/models-config.ts +379 -0
  18. package/extensions/rpiv-core/models-picker.test.ts +136 -0
  19. package/extensions/rpiv-core/models-picker.ts +121 -0
  20. package/extensions/rpiv-core/register-built-in-workflows.test.ts +16 -25
  21. package/extensions/rpiv-core/register-built-in-workflows.ts +10 -28
  22. package/extensions/rpiv-core/rpiv-models/command.ts +382 -0
  23. package/extensions/rpiv-core/rpiv-models/index.ts +42 -0
  24. package/extensions/rpiv-core/rpiv-models/items.ts +107 -0
  25. package/extensions/rpiv-core/rpiv-models/overrides.ts +419 -0
  26. package/extensions/rpiv-core/rpiv-models/units.test.ts +136 -0
  27. package/extensions/rpiv-core/rpiv-models-command.test.ts +797 -0
  28. package/extensions/rpiv-core/session-hooks.test.ts +49 -10
  29. package/extensions/rpiv-core/session-hooks.ts +36 -23
  30. package/extensions/rpiv-core/skill-bracket.test.ts +251 -0
  31. package/extensions/rpiv-core/skill-bracket.ts +118 -0
  32. package/extensions/rpiv-core/update-agents-command.test.ts +19 -0
  33. package/extensions/rpiv-core/update-agents-command.ts +8 -0
  34. package/extensions/rpiv-core/utils.test.ts +50 -0
  35. package/extensions/rpiv-core/utils.ts +39 -0
  36. package/package.json +1 -1
  37. package/skills/_shared/slice-overlap.mjs +193 -0
  38. package/skills/blueprint/SKILL.md +10 -1
package/README.md CHANGED
@@ -8,7 +8,9 @@
8
8
  </a>
9
9
  </div>
10
10
 
11
- > **Pi compatibility** - `rpiv-pi` `0.14.x` tracks `@earendil-works/pi-coding-agent` `0.70.x` and `@tintinweb/pi-subagents` `0.6.x`. If you see peer-dep resolution issues after a Pi upgrade, open an issue.
11
+ > **Pi compatibility** - `rpiv-pi` tracks `@earendil-works/pi-coding-agent` and `@tintinweb/pi-subagents` `0.10.x`. If you see peer-dep resolution issues after a Pi upgrade, open an issue.
12
+
13
+ > **⚠️ Upgrading to `@tintinweb/pi-subagents` `0.10.x`** - frontmatter tool gating changed: extension tools now route through `ext:<extension>/<tool>`. The bundled `web-search-researcher` is migrated - run `/rpiv-update-agents` to refresh it. Customised copies need a manual edit (see CHANGELOG).
12
14
 
13
15
  > **⚠️ Upgrading from `0.13.x`** - `1.0.0` swaps the subagent provider from `npm:pi-subagents` (nicobailon fork) back to `npm:@tintinweb/pi-subagents` (resumed maintenance). On first launch after upgrade you'll see *"rpiv-pi requires 1 sibling extension(s): @tintinweb/pi-subagents"* - **run `/rpiv-setup` once and restart Pi**. The setup dialog previews both changes (install `@tintinweb/pi-subagents`, remove `npm:pi-subagents` from `~/.pi/agent/settings.json`) and applies them only after you confirm. After restart, run `/rpiv-update-agents` to refresh the 12 bundled specialist frontmatters. Customised `<cwd>/.pi/agents/*.md` files are not touched. The tool name reverts from `subagent` → `Agent` (param `subagent_type`/`description`/`prompt`) - only your own custom skills/agents need editing; the bundled rpiv-pi specialists are migrated in this release.
14
16
 
@@ -169,7 +171,7 @@ Invoke via `/skill:<name>` from inside a Pi Agent session.
169
171
  | Command | Description |
170
172
  |---|---|
171
173
  | `/rpiv-setup` | Install all sibling plugins in one go |
172
- | `/rpiv-update-agents` | Refresh `~/.pi/agent/agents/` from bundled agent definitions and clean up legacy per-project agent directories |
174
+ | `/rpiv-update-agents` | Refresh `~/.pi/agent/agents/` from bundled agent definitions and clean up legacy per-project agent directories. Re-reads `models.json` before syncing, so mid-session per-agent `model`/`thinking` overrides take effect on disk |
173
175
  | `/advisor` | Configure advisor model and reasoning effort |
174
176
  | `/btw` | Ask a side question without polluting the main conversation _(requires `@juicesharp/rpiv-btw`, opt-in)_ |
175
177
  | `/languages` | Pick the UI language for rpiv-* TUI strings (Deutsch / English / Español / Français / Português / Português (Brasil) / Русский / Українська) |
@@ -210,12 +212,68 @@ Pi Agent discovers extensions via `"extensions": ["./extensions"]` and skills vi
210
212
 
211
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
212
214
  - **Advisor** - run `/advisor` to select a reviewer model and reasoning effort
215
+ - **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.
213
216
  - **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
214
217
  - **UI language** - run `/languages` to pick the locale for rpiv-* TUI strings, or pass `pi --locale <code>` at startup. Detection priority: flag → `~/.config/rpiv-i18n/locale.json` → `LANG` / `LC_ALL` → English. LLM-facing copy stays English by design
215
218
  - **Agent concurrency** - open the `/agents` overlay and tune `Settings → Max concurrency` to match your provider's rate limits. `@tintinweb/pi-subagents` owns this setting; rpiv-pi does not seed it.
216
219
  - **Agent profiles** - synced to `~/.pi/agent/agents/` from bundled defaults; refresh with `/rpiv-update-agents` (overwrites rpiv-managed files, preserves your custom agents).
217
220
  - **Non-default agent directory** - if you set `PI_CODING_AGENT_DIR` (e.g. `~/.config/pi/agent` for an XDG-style layout), rpiv-pi reads and writes the same `settings.json` Pi does — sibling detection, `/rpiv-setup`, and `/rpiv-update-agents` all follow the env var. Leading `~` is expanded.
218
221
 
222
+ ### Model configuration (models.json)
223
+
224
+ `rpiv-pi` reads `~/.config/rpiv-pi/models.json` to apply per-agent, per-stage, per-skill, and per-preset model + reasoning-effort overrides. The file is optional — missing or malformed JSON degrades to no overrides. Run `/rpiv-models` to edit it via cascade pickers, or hand-edit.
225
+
226
+ **Cascade ladder** (most specific first; each layer composes per-field against `defaults`):
227
+
228
+ 1. `presets[workflow].stages[stage]` — per-workflow per-stage override (e.g. `ship.plan`).
229
+ 2. `stages[stage]` — flat per-stage override (applies across every workflow that has it).
230
+ 3. `skills[skill]` — per-skill override; applies to **both** `/wf` workflow stages AND user-typed standalone `/skill:<name>` invocations.
231
+ 4. `defaults` — global fallback.
232
+
233
+ The standalone `/skill:` bracket has one exception: it arms ONLY on an explicit `skills[<name>]` entry. `defaults` does NOT trigger arming for user-typed `/skill:` invocations — your current session model stays sovereign.
234
+
235
+ **Worked example A — per-skill overrides for everyday short turns**:
236
+
237
+ ```json
238
+ {
239
+ "defaults": "anthropic/claude-opus-4-7",
240
+ "skills": {
241
+ "commit": "zai/glm-4-7",
242
+ "changelog": "zai/glm-4-7",
243
+ "research": { "model": "openai/gpt-5.5", "thinking": "high" }
244
+ }
245
+ }
246
+ ```
247
+
248
+ With this file, your default is Opus; `/skill:commit` and `/skill:changelog` use the cheaper GLM-4.7; `/skill:research` uses GPT-5.5 at high reasoning effort. Workflow-dispatched runs of the same skills get the same overrides (via the cascade's skill rung).
249
+
250
+ **Worked example B — per-workflow stage overrides for full pipelines**:
251
+
252
+ ```json
253
+ {
254
+ "defaults": "anthropic/claude-opus-4-7",
255
+ "presets": {
256
+ "ship": {
257
+ "stages": {
258
+ "plan": "openai/gpt-5.5",
259
+ "design": { "model": "openai/gpt-5.5", "thinking": "high" }
260
+ }
261
+ },
262
+ "polish": {
263
+ "stages": {
264
+ "plan": "zai/glm-4-7"
265
+ }
266
+ }
267
+ }
268
+ }
269
+ ```
270
+
271
+ With this file, `/wf ship plan` and `/wf ship design` use GPT-5.5; `/wf polish plan` uses GLM-4.7; everything else falls through to Opus. Per-workflow overrides take precedence over the flat `stages` block when both define the same stage.
272
+
273
+ **Model key form** — canonical is `provider/modelId` (slash-separated). The legacy `provider:modelId` (colon) form still parses for back-compatibility with persisted advisor configs; new saves emit slash form, and legacy values auto-migrate on the next save.
274
+
275
+ **Reasoning levels** — six values accepted in the `thinking` field: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. Note the distinction between **`off`** (explicitly disable reasoning) and **omitting** the field (inherit the session/baseline level). In `/rpiv-models` the effort picker offers `inherit (no override)` and `off (disable reasoning)` as separate choices. Any other value is rejected with a warning.
276
+
219
277
  ## Uninstall
220
278
 
221
279
  1. Remove rpiv-pi from Pi: `pi uninstall npm:@juicesharp/rpiv-pi`
@@ -37,6 +37,7 @@ The caller's dispatch prompt provides:
37
37
  - `slice_id` — identifier for the slice under audit, in whatever vocabulary the orchestrator uses
38
38
  - `current_slice_code` — verbatim content of the just-generated slice the orchestrator intends to lock, covering BOTH the code fences (every `#### N. path/...` block) AND the slice's success criteria (`### Success Criteria:` Automated + Manual subsections). When present, audit this AS the current slice; the artifact's `slice_id` section may legitimately be a skeleton (empty code fence + empty criteria) at this stage because writes are gated on developer approval. When absent, fall back to the artifact's `slice_id` section — and if that is also empty, the slice is truly missing and that is a real violation.
39
39
  - `target_files` — files the slice modifies, depends on, or assumes about
40
+ - `overlapping_priors` — OPTIONAL. Precomputed list of priors sharing a file/symbol with this slice; drives Step 3 when present.
40
41
 
41
42
  Read the artifact in full (no limit/offset). Read every target file in full.
42
43
 
@@ -48,7 +49,9 @@ Locate the artifact's commitments — architectural decisions, contracts, scoped
48
49
 
49
50
  ### Step 3: Cross-slice audit
50
51
 
51
- Walk every change/file in every locked prior slice (slice headings preceding `slice_id` in artifact order). For each: state what it produced, check the current slice for overlaps/collisions/redeclarations, verify every cross-slice symbol reference matches character-for-character, verify every claim the current slice makes about prior-slice behaviors against the projected intermediate state.
52
+ If `overlapping_priors` is given, trust it: deep-walk exactly those prior slices, collapse the rest to one `no overlap — <slice ids>` note. Otherwise, partition locked prior slices (headings preceding `slice_id` in artifact order) by overlap with the current slice: a prior slice OVERLAPS if it touches a `target_files` entry OR declares a symbol the current slice references. Non-overlapping slices cannot collide collapse them to one aggregate note (`no overlap <slice ids>`) and do not walk them.
53
+
54
+ Walk every OVERLAPPING prior slice in full. For each: state what it produced, check the current slice for overlaps/collisions/redeclarations, verify every cross-slice symbol reference matches character-for-character, verify every claim the current slice makes about prior-slice behaviors against the projected intermediate state.
52
55
 
53
56
  The projected intermediate state is HEAD plus every locked prior slice's code fence applied in order — a symbol, file, or export declared NEW in an upstream slice exists in that pre-state even though it is absent from HEAD. Verify cross-slice references against the upstream slice's code fence in the artifact, not against the live working tree.
54
57
 
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: web-search-researcher
3
3
  description: Do you find yourself desiring information that you don't quite feel well-trained (confident) on? Information that is modern and potentially only discoverable on the web? Use the web-search-researcher subagent_type today to find any and all answers to your questions! It will research deeply to figure out and attempt to answer your questions! If you aren't immediately satisfied you can get your money back! (Not really - but you can re-run web-search-researcher with an altered prompt in the event you're not satisfied the first time)
4
- tools: web_search, web_fetch, read, grep, find, ls
4
+ extensions: [rpiv-web-tools]
5
+ tools: read, grep, find, ls, ext:rpiv-web-tools/web_search, ext:rpiv-web-tools/web_fetch
5
6
  ---
6
7
 
7
8
  You are an expert web research specialist focused on finding accurate, relevant information from web sources. Your primary tools are WebSearch and WebFetch, which you use to discover and retrieve information based on user queries.
@@ -16,11 +16,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
  import {
17
17
  CLEANUP_SKIP_REASON,
18
18
  cleanupPerCwdAgents,
19
+ injectModelFrontmatter,
19
20
  isSafeDestructiveOp,
20
21
  SYNC_OP,
21
22
  summarizeCleanupSkips,
22
23
  syncBundledAgents,
23
24
  } from "./agents.js";
25
+ import type { ModelsConfig } from "./models-config.js";
24
26
  import { BUNDLED_AGENTS_DIR } from "./paths.js";
25
27
 
26
28
  const sha256 = (s: string | Buffer) => createHash("sha256").update(s).digest("hex");
@@ -915,3 +917,121 @@ describe("isSafeDestructiveOp", () => {
915
917
  expect(isSafeDestructiveOp({ hasV2Data: false, knownHash: HASH_A, destHash: HASH_B })).toBe(false);
916
918
  });
917
919
  });
920
+
921
+ // ─────────────────────────────────────────────────────────────────────────────
922
+ // Agent frontmatter injection (Phase 2)
923
+ // ─────────────────────────────────────────────────────────────────────────────
924
+
925
+ describe("agent frontmatter injection", () => {
926
+ const agentContent = [
927
+ "---",
928
+ "name: test-agent",
929
+ "description: Test agent",
930
+ "tools: grep, find",
931
+ "isolated: true",
932
+ "---",
933
+ "",
934
+ "You are a test agent.",
935
+ ].join("\n");
936
+
937
+ // --- Direct unit tests on the pure transform (the load-bearing invariants) ---
938
+
939
+ const cfg: ModelsConfig = {
940
+ agents: { "test-agent": { model: "anthropic/claude-sonnet-4-20250514", thinking: "high" } },
941
+ };
942
+
943
+ it("injects model and thinking before the closing ---", () => {
944
+ const out = injectModelFrontmatter(agentContent, "test-agent.md", cfg);
945
+ // Post-slash-canonical migration: models.json value passes through to
946
+ // frontmatter byte-for-byte. No translation step.
947
+ expect(out).toContain("model: anthropic/claude-sonnet-4-20250514");
948
+ expect(out).toContain("thinking: high");
949
+ // Injected keys land inside the frontmatter block, before the body.
950
+ const fmEnd = out.indexOf("\n---", 3);
951
+ expect(out.indexOf("model:")).toBeLessThan(fmEnd);
952
+ expect(out.indexOf("You are a test agent.")).toBeGreaterThan(fmEnd);
953
+ });
954
+
955
+ it("is idempotent — inject(inject(x)) === inject(x) (drift prevention)", () => {
956
+ const once = injectModelFrontmatter(agentContent, "test-agent.md", cfg);
957
+ const twice = injectModelFrontmatter(once, "test-agent.md", cfg);
958
+ expect(twice).toBe(once);
959
+ });
960
+
961
+ it("emits the models.json model value byte-for-byte (strengthened idempotency)", () => {
962
+ const out = injectModelFrontmatter(agentContent, "test-agent.md", cfg);
963
+ const fmModelLine = out.split("\n").find((l) => l.startsWith("model: "));
964
+ // Post-slash-canonical: the frontmatter `model:` value equals the
965
+ // models.json `model` field char-for-char — no translation layer.
966
+ expect(fmModelLine).toBe(`model: ${cfg.agents!["test-agent"].model}`);
967
+ });
968
+
969
+ it("returns content unchanged when no override is configured", () => {
970
+ expect(injectModelFrontmatter(agentContent, "other-agent.md", cfg)).toBe(agentContent);
971
+ expect(injectModelFrontmatter(agentContent, "test-agent.md", {})).toBe(agentContent);
972
+ });
973
+
974
+ it("replaces an existing model key in place rather than duplicating it", () => {
975
+ const withModel = agentContent.replace("name: test-agent", "name: test-agent\nmodel: openai/gpt-5.5");
976
+ const out = injectModelFrontmatter(withModel, "test-agent.md", cfg);
977
+ expect(out.match(/^model:/gm)?.length).toBe(1);
978
+ expect(out).toContain("model: anthropic/claude-sonnet-4-20250514");
979
+ });
980
+
981
+ it("injects an explicit thinking: off (disable reasoning) and stays idempotent", () => {
982
+ const offCfg: ModelsConfig = { agents: { "test-agent": { model: "anthropic/opus", thinking: "off" } } };
983
+ const out = injectModelFrontmatter(agentContent, "test-agent.md", offCfg);
984
+ expect(out).toContain("thinking: off");
985
+ expect(injectModelFrontmatter(out, "test-agent.md", offCfg)).toBe(out);
986
+ });
987
+
988
+ it("cascades a defaults model into an otherwise-unconfigured agent", () => {
989
+ const defaultsCfg: ModelsConfig = {
990
+ defaults: { model: "openai/o3-pro" },
991
+ agents: { "other-agent": { model: "openai/o3-pro" } },
992
+ };
993
+ const out = injectModelFrontmatter(agentContent, "test-agent.md", defaultsCfg);
994
+ expect(out).toContain("model: openai/o3-pro");
995
+ });
996
+
997
+ // --- End-to-end sync seam tests (real bundled agent) ---
998
+
999
+ const REAL_AGENT = "codebase-analyzer.md";
1000
+ const writeModels = (config: unknown) => {
1001
+ const dir = join(homedir(), ".config", "rpiv-pi");
1002
+ mkdirSync(dir, { recursive: true });
1003
+ writeFileSync(join(dir, "models.json"), JSON.stringify(config), "utf-8");
1004
+ };
1005
+ const destContent = (name: string) => readFileSync(join(homedir(), ".pi", "agent", "agents", name), "utf-8");
1006
+
1007
+ it("injects model and thinking into the synced agent .md file", () => {
1008
+ writeModels({ agents: { "codebase-analyzer": { model: "openai/o3-pro", thinking: "high" } } });
1009
+
1010
+ const result = syncBundledAgents(true);
1011
+ expect([...result.added, ...result.updated, ...result.unchanged]).toContain(REAL_AGENT);
1012
+
1013
+ const written = destContent(REAL_AGENT);
1014
+ expect(written).toContain("model: openai/o3-pro");
1015
+ expect(written).toContain("thinking: high");
1016
+ });
1017
+
1018
+ it("produces no false pendingUpdate when re-synced (idempotent on disk)", () => {
1019
+ writeModels({ agents: { "codebase-analyzer": { model: "openai/o3-pro", thinking: "high" } } });
1020
+
1021
+ syncBundledAgents(true);
1022
+ const result2 = syncBundledAgents(false);
1023
+
1024
+ // Re-sync must see the injected agent as unchanged, never pendingUpdate.
1025
+ expect(result2.pendingUpdate).not.toContain(REAL_AGENT);
1026
+ expect(result2.unchanged).toContain(REAL_AGENT);
1027
+ });
1028
+
1029
+ it("does not inject when no config exists for the agent", () => {
1030
+ // No models.json — global test setup already removed it in beforeEach.
1031
+ const result = syncBundledAgents(true);
1032
+ expect([...result.added, ...result.updated, ...result.unchanged]).toContain(REAL_AGENT);
1033
+
1034
+ // Dest content must equal the raw bundled source — no frontmatter injected.
1035
+ expect(destContent(REAL_AGENT)).toBe(bundledContent(REAL_AGENT));
1036
+ });
1037
+ });
@@ -16,7 +16,6 @@
16
16
 
17
17
  import { createHash } from "node:crypto";
18
18
  import {
19
- copyFileSync,
20
19
  existsSync,
21
20
  mkdirSync,
22
21
  readdirSync,
@@ -28,6 +27,8 @@ import {
28
27
  } from "node:fs";
29
28
  import { isAbsolute, join, resolve, sep } from "node:path";
30
29
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
30
+ import { parseFrontmatterBounds } from "./frontmatter.js";
31
+ import { getAgentModelConfig, loadModelsConfig, type ModelsConfig } from "./models-config.js";
31
32
  import { BUNDLED_AGENTS_DIR } from "./paths.js";
32
33
  import { isPlainObject, toErrorMessage } from "./utils.js";
33
34
 
@@ -327,6 +328,80 @@ function enumerateSourceFiles(result: SyncResult): string[] | null {
327
328
  }
328
329
  }
329
330
 
331
+ /**
332
+ * Apply key-value updates to frontmatter lines.
333
+ *
334
+ * For each key in `keysToSet`, replaces the existing line if present
335
+ * (within the frontmatter bounds), or inserts a new line before the
336
+ * closing `---`. Returns the (possibly modified) lines array.
337
+ *
338
+ * The closing-fence index (`bounds.end`) is stable across in-place
339
+ * replacements — only the value changes, not the line count — so the
340
+ * insertion point remains correct without re-scanning.
341
+ */
342
+ function applyKeyUpdates(
343
+ lines: string[],
344
+ bounds: { start: number; end: number },
345
+ keysToSet: { key: string; value: string }[],
346
+ ): string[] {
347
+ const result = [...lines];
348
+ const insertLines: string[] = [];
349
+
350
+ for (const { key, value } of keysToSet) {
351
+ const prefix = `${key}: `;
352
+ const existingIdx = result.findIndex((line, i) => i > 0 && i < bounds.end && line.startsWith(prefix));
353
+ if (existingIdx !== -1) {
354
+ result[existingIdx] = `${prefix}${value}`;
355
+ } else {
356
+ insertLines.push(`${prefix}${value}`);
357
+ }
358
+ }
359
+
360
+ if (insertLines.length > 0) {
361
+ result.splice(bounds.end, 0, ...insertLines);
362
+ }
363
+
364
+ return result;
365
+ }
366
+
367
+ /**
368
+ * Inject model/thinking frontmatter into agent .md content.
369
+ *
370
+ * Idempotent: re-injecting produces identical bytes. The function finds
371
+ * the closing `---` of the YAML frontmatter block and inserts or replaces
372
+ * `model:` and `thinking:` lines deterministically.
373
+ *
374
+ * If no override is configured for this agent, returns content unchanged.
375
+ *
376
+ * Exported so the idempotency invariant can be unit-tested directly:
377
+ * `inject(inject(x)) === inject(x)` (see Verification Notes).
378
+ */
379
+ export function injectModelFrontmatter(content: string, agentFile: string, config: ModelsConfig): string {
380
+ // Strip .md extension — source entries are filenames like "codebase-analyzer.md"
381
+ // but models.json keys are agent names like "codebase-analyzer".
382
+ const agentKey = agentFile.replace(/\.md$/, "");
383
+ const override = getAgentModelConfig(config, agentKey);
384
+ if (!override || (override.model === undefined && override.thinking === undefined)) {
385
+ return content;
386
+ }
387
+
388
+ const lines = content.split("\n");
389
+ const bounds = parseFrontmatterBounds(lines);
390
+ if (!bounds) return content;
391
+
392
+ const keysToSet: { key: string; value: string }[] = [];
393
+ // D9 (post-slash-canonical migration): models.json values are byte-equal to
394
+ // the agent frontmatter form (both `provider/modelId`). No translation step
395
+ // — re-injecting produces identical bytes by construction; the idempotency
396
+ // invariant at injectModelFrontmatter's JSDoc strengthens from "deterministic
397
+ // translation" to "byte pass-through".
398
+ if (override.model !== undefined) keysToSet.push({ key: "model", value: override.model });
399
+ if (override.thinking !== undefined) keysToSet.push({ key: "thinking", value: override.thinking });
400
+
401
+ const updated = applyKeyUpdates(lines, bounds, keysToSet);
402
+ return updated.join("\n");
403
+ }
404
+
330
405
  /**
331
406
  * Step 2: Process each source file — copy new, record unchanged, update or gate.
332
407
  * Returns the new manifest built from source entries.
@@ -341,6 +416,10 @@ function processSourceEntries(
341
416
  ): Manifest {
342
417
  const newManifest: Manifest = {};
343
418
 
419
+ // Hoisted above the loop: loadModelsConfig() reads+parses JSON, so calling it
420
+ // per-entry would re-read the file once per agent (~15×) every session_start.
421
+ const config = loadModelsConfig();
422
+
344
423
  for (const entry of sourceEntries) {
345
424
  const src = join(BUNDLED_AGENTS_DIR, entry);
346
425
  const dest = safeJoin(targetDir, entry);
@@ -359,11 +438,16 @@ function processSourceEntries(
359
438
  newManifest[entry] = knownHash;
360
439
  continue;
361
440
  }
362
- const srcHash = sha256(srcContent);
441
+ // Inject configured model/thinking frontmatter BEFORE hashing so the
442
+ // manifest hash matches what actually lands on disk (D4: hash-after-transform).
443
+ // injectModelFrontmatter strips .md from entry for config lookup and is a
444
+ // no-op when no override is configured.
445
+ const injected = injectModelFrontmatter(srcContent.toString("utf-8"), entry, config);
446
+ const srcHash = sha256(injected);
363
447
 
364
448
  if (!existsSync(dest)) {
365
449
  try {
366
- copyFileSync(src, dest);
450
+ writeFileSync(dest, injected, "utf-8");
367
451
  result.added.push(entry);
368
452
  newManifest[entry] = srcHash;
369
453
  } catch (e) {
@@ -391,7 +475,7 @@ function processSourceEntries(
391
475
 
392
476
  if (apply || isSafeDestructiveOp({ hasV2Data, knownHash, destHash })) {
393
477
  try {
394
- copyFileSync(src, dest);
478
+ writeFileSync(dest, injected, "utf-8");
395
479
  result.updated.push(entry);
396
480
  newManifest[entry] = srcHash;
397
481
  } catch (e) {
@@ -581,7 +665,10 @@ export function cleanupPerCwdAgents(cwd: string): CleanupResult {
581
665
  return result;
582
666
  }
583
667
 
584
- // Edge state 2: verify all managed files match current source content
668
+ // Edge state 2: verify all managed files match current source content.
669
+ // Hoisted config read (same reasoning as processSourceEntries): one JSON
670
+ // read for the whole cleanup pass, not one per managed file.
671
+ const cleanupConfig = loadModelsConfig();
585
672
  for (const [name] of Object.entries(manifest)) {
586
673
  const srcPath = safeJoin(BUNDLED_AGENTS_DIR, name);
587
674
  const destPath = safeJoin(perCwdDir, name);
@@ -615,7 +702,11 @@ export function cleanupPerCwdAgents(cwd: string): CleanupResult {
615
702
  return result;
616
703
  }
617
704
 
618
- if (sha256(destContent) !== sha256(srcContent)) {
705
+ // Compare against the injected form — the dest holds injected content
706
+ // (D4), so comparing raw source would falsely flag every configured agent
707
+ // as diverged. injectModelFrontmatter strips .md from name for lookup.
708
+ const cleanupInjected = injectModelFrontmatter(srcContent.toString("utf-8"), name, cleanupConfig);
709
+ if (sha256(destContent) !== sha256(cleanupInjected)) {
619
710
  // User edited this file — conservative gate.
620
711
  result.skipped.push({ dir: perCwdDir, reason: CLEANUP_SKIP_REASON.DIVERGED });
621
712
  return result;
@@ -34,7 +34,8 @@ import {
34
34
  type OutputSpec,
35
35
  type ParseCtx,
36
36
  transcriptPathCollector,
37
- } from "@juicesharp/rpiv-workflow";
37
+ // Runner-free entry — keeps the ~530ms engine off the startup path.
38
+ } from "@juicesharp/rpiv-workflow/registration";
38
39
 
39
40
  // ---------------------------------------------------------------------------
40
41
  // Collectors — text-scan over assistant transcript
@@ -34,7 +34,7 @@ import {
34
34
  type RunState,
35
35
  typeboxSchema,
36
36
  type Workflow,
37
- } from "@juicesharp/rpiv-workflow";
37
+ } from "@juicesharp/rpiv-workflow/registration";
38
38
  import { Type } from "typebox";
39
39
  import { rpivBucketOutcome } from "./artifact-collector.js";
40
40
 
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseFrontmatterBounds } from "./frontmatter.js";
3
+
4
+ describe("parseFrontmatterBounds", () => {
5
+ it("returns bounds for well-formed frontmatter", () => {
6
+ const content = ["---", "name: test", "---", "body"].join("\n");
7
+ expect(parseFrontmatterBounds(content.split("\n"))).toEqual({ start: 0, end: 2 });
8
+ });
9
+
10
+ it("returns bounds when frontmatter has many lines", () => {
11
+ const content = ["---", "name: test", "description: long", "tools: grep", "---", "body"].join("\n");
12
+ expect(parseFrontmatterBounds(content.split("\n"))).toEqual({ start: 0, end: 4 });
13
+ });
14
+
15
+ it("returns bounds when content ends immediately after closing ---", () => {
16
+ const content = ["---", "name: test", "---"].join("\n");
17
+ expect(parseFrontmatterBounds(content.split("\n"))).toEqual({ start: 0, end: 2 });
18
+ });
19
+
20
+ it("returns null when content is empty", () => {
21
+ expect(parseFrontmatterBounds("".split("\n"))).toBeNull();
22
+ });
23
+
24
+ it("returns null when there is no opening ---", () => {
25
+ const content = "name: test\n---\nbody";
26
+ expect(parseFrontmatterBounds(content.split("\n"))).toBeNull();
27
+ });
28
+
29
+ it("returns null when there is no closing ---", () => {
30
+ const content = ["---", "name: test", "body"].join("\n");
31
+ expect(parseFrontmatterBounds(content.split("\n"))).toBeNull();
32
+ });
33
+
34
+ it("returns null for single-line content with no ---", () => {
35
+ expect(parseFrontmatterBounds("just text".split("\n"))).toBeNull();
36
+ });
37
+
38
+ it("returns null for content that is only opening ---", () => {
39
+ expect(parseFrontmatterBounds("---".split("\n"))).toBeNull();
40
+ });
41
+
42
+ it("handles frontmatter with empty lines between keys", () => {
43
+ const content = ["---", "name: test", "", "tools: grep", "---", "body"].join("\n");
44
+ expect(parseFrontmatterBounds(content.split("\n"))).toEqual({ start: 0, end: 4 });
45
+ });
46
+
47
+ it("picks the first closing --- after the opening", () => {
48
+ const content = ["---", "name: test", "---", "---", "body"].join("\n");
49
+ expect(parseFrontmatterBounds(content.split("\n"))).toEqual({ start: 0, end: 2 });
50
+ });
51
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Frontmatter utilities for rpiv-core.
3
+ *
4
+ * Pure functions — no ExtensionAPI, no side effects, fail-soft.
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Frontmatter bounds
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Find the line indices of the YAML frontmatter block in `content`.
13
+ *
14
+ * Returns `{ start, end }` where `start` is the 0-based line index of the
15
+ * opening `---` and `end` is the 0-based line index of the closing `---`.
16
+ * Returns `null` when the content has no valid frontmatter block (missing
17
+ * opening fence, missing closing fence, or empty content).
18
+ *
19
+ * Takes a pre-split lines array so callers can reuse the same split for
20
+ * both bounds detection and subsequent mutation without double-splitting.
21
+ */
22
+ export function parseFrontmatterBounds(lines: string[]): { start: number; end: number } | null {
23
+ if (lines[0] !== "---") return null;
24
+
25
+ for (let i = 1; i < lines.length; i++) {
26
+ if (lines[i] === "---") {
27
+ return { start: 0, end: i };
28
+ }
29
+ }
30
+
31
+ return null; // unclosed frontmatter
32
+ }
@@ -7,16 +7,20 @@
7
7
  * Tool-owning plugins are siblings (see siblings.ts); install via /rpiv-setup.
8
8
  *
9
9
  * Workflow runtime + `/wf` command live in `@juicesharp/rpiv-workflow`. We
10
- * contribute three built-in workflows (small / mid / large) via the
10
+ * contribute five built-in workflows (ship / build / arch / vet / polish) via the
11
11
  * sibling's `registerBuiltIns` programmatic API so they're available to
12
12
  * users running `/wf` without authoring their own.
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import { FLAG_DEBUG } from "./constants.js";
17
+ import { registerModelOverrideLifecycle, registerModelOverrideSessionStart } from "./model-override.js";
18
+ import { registerModelsConfigValidation } from "./models-config-validate.js";
17
19
  import { registerBuiltInWorkflows } from "./register-built-in-workflows.js";
20
+ import { registerRpivModelsCommand } from "./rpiv-models/index.js";
18
21
  import { registerSessionHooks } from "./session-hooks.js";
19
22
  import { registerSetupCommand } from "./setup-command.js";
23
+ import { registerSkillBracket } from "./skill-bracket.js";
20
24
  import { registerUpdateAgentsCommand } from "./update-agents-command.js";
21
25
 
22
26
  export default function (pi: ExtensionAPI) {
@@ -31,11 +35,36 @@ export default function (pi: ExtensionAPI) {
31
35
  registerSessionHooks(pi);
32
36
  registerUpdateAgentsCommand(pi);
33
37
  registerSetupCommand(pi);
34
- // Built-in workflows feed the sibling's `/wf` command. Deferred behind a
35
- // dynamic import so a missing sibling degrades gracefully instead of taking
36
- // the whole extension down (see register-built-in-workflows.ts). Fire-and-
37
- // forget: the registry is read lazily at `/wf` time, long after this settles.
38
- registerBuiltInWorkflows().catch((err: unknown) => {
39
- console.error("[rpiv-core] failed to register built-in workflows:", err);
40
- });
38
+ registerRpivModelsCommand(pi); // /rpiv-models cascade picker
39
+ // Warn-on-miss: surface models.json record-key typos (skills.committ,
40
+ // presets.shipp) that pass schema validation but silently never apply.
41
+ registerModelsConfigValidation(pi);
42
+ // Stage model/effort override: the session_start hook captures modelRegistry +
43
+ // current model UNCONDITIONALLY (independent of rpiv-workflow), and the
44
+ // lifecycle listener registration degrades gracefully when the sibling is
45
+ // absent (isModuleNotFound guard inside registerModelOverrideLifecycle).
46
+ registerModelOverrideSessionStart(pi);
47
+ // Standalone /skill: model/effort override bracket. MUST register AFTER
48
+ // registerModelOverrideSessionStart so the bracket's `getCapturedModel()`
49
+ // read at input-arm time sees the populated baseline. The bracket's
50
+ // `input` + `agent_end` handlers are independent of rpiv-workflow's
51
+ // presence — they read models.json directly.
52
+ registerSkillBracket(pi);
53
+ // Both registerModelOverrideLifecycle and registerBuiltInWorkflows dynamically
54
+ // `import("@juicesharp/rpiv-workflow")`. Firing them concurrently makes jiti
55
+ // (Pi's dev loader) hand the second caller a half-initialized barrel namespace
56
+ // whose re-export getters (e.g. registerBuiltIns) read from a not-yet-evaluated
57
+ // submodule and throw "Cannot read properties of undefined". Chaining them means
58
+ // the second import resolves from jiti's module cache after the first has fully
59
+ // evaluated the barrel — no race. Both are fire-and-forget (the workflow
60
+ // registry is read lazily at `/wf` time, long after this settles) and both
61
+ // degrade gracefully when the sibling is absent (isModuleNotFound guards).
62
+ const logRegistrationFailure = (label: string) => (err: unknown) =>
63
+ console.error(`[rpiv-core] failed to register ${label}:`, err);
64
+
65
+ registerModelOverrideLifecycle(pi)
66
+ .catch(logRegistrationFailure("model override lifecycle"))
67
+ .finally(() => {
68
+ registerBuiltInWorkflows().catch(logRegistrationFailure("built-in workflows"));
69
+ });
41
70
  }