@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.
- package/README.md +60 -2
- package/agents/slice-verifier.md +4 -1
- package/agents/web-search-researcher.md +2 -1
- package/extensions/rpiv-core/agents.test.ts +120 -0
- package/extensions/rpiv-core/agents.ts +97 -6
- package/extensions/rpiv-core/artifact-collector.ts +2 -1
- package/extensions/rpiv-core/built-in-workflows.ts +1 -1
- package/extensions/rpiv-core/frontmatter.test.ts +51 -0
- package/extensions/rpiv-core/frontmatter.ts +32 -0
- package/extensions/rpiv-core/index.ts +37 -8
- package/extensions/rpiv-core/model-override.test.ts +559 -0
- package/extensions/rpiv-core/model-override.ts +328 -0
- package/extensions/rpiv-core/models-config-sources.ts +51 -0
- package/extensions/rpiv-core/models-config-validate.test.ts +131 -0
- package/extensions/rpiv-core/models-config-validate.ts +70 -0
- package/extensions/rpiv-core/models-config.test.ts +461 -0
- package/extensions/rpiv-core/models-config.ts +379 -0
- package/extensions/rpiv-core/models-picker.test.ts +136 -0
- package/extensions/rpiv-core/models-picker.ts +121 -0
- package/extensions/rpiv-core/register-built-in-workflows.test.ts +16 -25
- package/extensions/rpiv-core/register-built-in-workflows.ts +10 -28
- package/extensions/rpiv-core/rpiv-models/command.ts +382 -0
- package/extensions/rpiv-core/rpiv-models/index.ts +42 -0
- package/extensions/rpiv-core/rpiv-models/items.ts +107 -0
- package/extensions/rpiv-core/rpiv-models/overrides.ts +419 -0
- package/extensions/rpiv-core/rpiv-models/units.test.ts +136 -0
- package/extensions/rpiv-core/rpiv-models-command.test.ts +797 -0
- package/extensions/rpiv-core/session-hooks.test.ts +49 -10
- package/extensions/rpiv-core/session-hooks.ts +36 -23
- package/extensions/rpiv-core/skill-bracket.test.ts +251 -0
- package/extensions/rpiv-core/skill-bracket.ts +118 -0
- package/extensions/rpiv-core/update-agents-command.test.ts +19 -0
- package/extensions/rpiv-core/update-agents-command.ts +8 -0
- package/extensions/rpiv-core/utils.test.ts +50 -0
- package/extensions/rpiv-core/utils.ts +39 -0
- package/package.json +1 -1
- package/skills/_shared/slice-overlap.mjs +193 -0
- 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`
|
|
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`
|
package/agents/slice-verifier.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
}
|