omp-llm-role 1.0.0 → 1.2.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 +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
package/src/features.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in capability presets (issue #40): one named flag per capability, each
|
|
3
|
+
* applying the plugin's recommended settings to a role. This module is the
|
|
4
|
+
* single source of truth for the registry and the single implementation of the
|
|
5
|
+
* precedence rule (`expandFeatures`), consumed by `resolveSettings` and (phase 2)
|
|
6
|
+
* the explorer's rank/explain handlers.
|
|
7
|
+
*
|
|
8
|
+
* Dual-runtime rule: only `node:` builtins, relative imports with explicit `.ts`
|
|
9
|
+
* extensions, `import type` for types.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RoleDef } from "./engine.ts";
|
|
13
|
+
|
|
14
|
+
export type FeatureId = "endpointCeilings" | "cachePricing" | "providerPinning" | "costCap";
|
|
15
|
+
|
|
16
|
+
export type FeatureRow = {
|
|
17
|
+
id: FeatureId;
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** The recommended settings the flag applies. A bundle value is either a
|
|
21
|
+
* scalar knob or a per-model policy field (`preferOwnProvider`). */
|
|
22
|
+
recommended: { filters?: { tools?: boolean; minOutputTokens?: number; maxPriceUsdPerM?: number; preferredMinThroughput?: number }; cacheHitRate?: number; preferOwnProvider?: true };
|
|
23
|
+
/** What the flag buys, for `--list-features` and the docs. */
|
|
24
|
+
buys: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const FEATURES: readonly FeatureRow[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "endpointCeilings",
|
|
30
|
+
label: "Endpoint capability ceilings",
|
|
31
|
+
description: "Drop routes that cannot call tools and routes whose output ceiling is below 16 384 tokens",
|
|
32
|
+
recommended: { filters: { tools: true, minOutputTokens: 16384 } },
|
|
33
|
+
buys: "routes that cannot call tools and routes whose output ceiling is below 16 384 tokens leave the pool — a reasoning model is not cut off mid-thought",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "cachePricing",
|
|
37
|
+
label: "Cache-read pricing",
|
|
38
|
+
description: "Price the role on a cache-heavy agent loop's invoice (cacheHitRate 0.5) instead of the sticker price",
|
|
39
|
+
recommended: { cacheHitRate: 0.5 },
|
|
40
|
+
buys: "the role is priced on a cache-heavy agent loop's invoice (the endpoint's cacheReadPrice blended in) rather than the sticker price",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "providerPinning",
|
|
44
|
+
label: "Best-route provider pinning",
|
|
45
|
+
// Per-model policy, not a scalar slug: the sensible pin differs per model,
|
|
46
|
+
// so the bundle carries `preferOwnProvider` (evaluated against the model's
|
|
47
|
+
// best route at rank time). It changes no eligibility — a model with no
|
|
48
|
+
// candidate route keeps the default 1/price² blend.
|
|
49
|
+
description: "Bind each request to the model's best provider route (per-model policy; never drops a model) and prefer routes at or above 50 tok/s",
|
|
50
|
+
recommended: { preferOwnProvider: true, filters: { preferredMinThroughput: 50 } },
|
|
51
|
+
buys: "each request is bound to the model's best provider route — the selector gains @<slug> — and the fallback chain carries the same model on its next-best providers plus each fallback model on its best providers, so a down route fails over within the model's own routes; a soft 50 tok/s floor deprioritizes a pathologically slow route without dropping it",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "costCap",
|
|
55
|
+
label: "Cost cap",
|
|
56
|
+
description: "Drop the priciest tail of the eligible pool (maxPriceUsdPerM 10)",
|
|
57
|
+
recommended: { filters: { maxPriceUsdPerM: 10 } },
|
|
58
|
+
buys: "the priciest tail of the eligible pool leaves it, so the pick cannot be an expensive outlier",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function featureById(id: string): FeatureRow | null {
|
|
63
|
+
return FEATURES.find((f) => f.id === id) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Render a bundle as `key=value` pairs for `--list-features`. */
|
|
67
|
+
function formatBundle(rec: FeatureRow["recommended"]): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
if (rec.filters?.tools !== undefined) parts.push(`filters.tools=${rec.filters.tools}`);
|
|
70
|
+
if (rec.filters?.minOutputTokens !== undefined) parts.push(`filters.minOutputTokens=${rec.filters.minOutputTokens}`);
|
|
71
|
+
if (rec.filters?.maxPriceUsdPerM !== undefined) parts.push(`filters.maxPriceUsdPerM=${rec.filters.maxPriceUsdPerM}`);
|
|
72
|
+
if (rec.filters?.preferredMinThroughput !== undefined) parts.push(`filters.preferredMinThroughput=${rec.filters.preferredMinThroughput}`);
|
|
73
|
+
if (rec.cacheHitRate !== undefined) parts.push(`cacheHitRate=${rec.cacheHitRate}`);
|
|
74
|
+
if (rec.preferOwnProvider !== undefined) parts.push(`preferOwnProvider=${rec.preferOwnProvider}`);
|
|
75
|
+
return parts.join(", ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One human-readable line per feature for `--list-features`. */
|
|
79
|
+
export function formatFeatures(): string {
|
|
80
|
+
return FEATURES.map((f) => `${f.id} — ${f.label}: ${f.buys} [${formatBundle(f.recommended)}]`).join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The ONE implementation of the precedence rule. Fills only the knobs the
|
|
84
|
+
* authored def does NOT already set, then strips `features` from the result.
|
|
85
|
+
* Never mutates `authored`. */
|
|
86
|
+
export function expandFeatures(authored: RoleDef, globalFlags: Record<string, boolean>): RoleDef {
|
|
87
|
+
const out = structuredClone(authored) as RoleDef;
|
|
88
|
+
delete out.features;
|
|
89
|
+
for (const row of FEATURES) {
|
|
90
|
+
const authoredFlag = authored.features?.[row.id];
|
|
91
|
+
const on = authoredFlag !== undefined ? authoredFlag : globalFlags[row.id] === true;
|
|
92
|
+
if (!on) continue;
|
|
93
|
+
const rec = row.recommended;
|
|
94
|
+
if (rec.filters) {
|
|
95
|
+
const f = (out.filters ??= {});
|
|
96
|
+
if (rec.filters.tools !== undefined && f.tools === undefined) f.tools = rec.filters.tools;
|
|
97
|
+
if (rec.filters.minOutputTokens !== undefined && f.minOutputTokens === undefined) f.minOutputTokens = rec.filters.minOutputTokens;
|
|
98
|
+
if (rec.filters.maxPriceUsdPerM !== undefined && f.maxPriceUsdPerM === undefined) f.maxPriceUsdPerM = rec.filters.maxPriceUsdPerM;
|
|
99
|
+
if (rec.filters.preferredMinThroughput !== undefined && f.preferredMinThroughput === undefined) f.preferredMinThroughput = rec.filters.preferredMinThroughput;
|
|
100
|
+
}
|
|
101
|
+
if (rec.cacheHitRate !== undefined && out.cacheHitRate === undefined) out.cacheHitRate = rec.cacheHitRate;
|
|
102
|
+
if (rec.preferOwnProvider !== undefined && out.preferOwnProvider === undefined) out.preferOwnProvider = rec.preferOwnProvider;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's memory of the projects where `/project-roles` was used: a small
|
|
3
|
+
* JSON file in the **global** agent dir (never a project's `.omp`), so the
|
|
4
|
+
* explorer can list every project-scoped role set without scanning the
|
|
5
|
+
* filesystem.
|
|
6
|
+
*
|
|
7
|
+
* Written by the project-aware `runUpdater` on any project-scoped run — before
|
|
8
|
+
* the day-gate early return, so a day-stamped no-op session start still
|
|
9
|
+
* registers — pruned of entries whose project lock file is gone, and replaced
|
|
10
|
+
* atomically (temp + rename) so two sessions in different repos cannot
|
|
11
|
+
* interleave into a corrupt file. Reading is pure and tolerant: a missing or
|
|
12
|
+
* unparseable file yields [].
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { isRecord } from "./guards.ts";
|
|
18
|
+
import { projectLockPath } from "./settings.ts";
|
|
19
|
+
import { agentDir } from "./state.ts";
|
|
20
|
+
|
|
21
|
+
/** Registry file name inside the agent dir. */
|
|
22
|
+
export const PROJECT_REGISTRY_FILE = "llm-role-projects.json";
|
|
23
|
+
|
|
24
|
+
export type ProjectRegistryEntry = { root: string; lastUsed: string };
|
|
25
|
+
|
|
26
|
+
/** Read the registry. Missing, unparseable, or unexpected-shape → []. */
|
|
27
|
+
export function readProjectRegistry(dir = agentDir()): ProjectRegistryEntry[] {
|
|
28
|
+
let parsed: unknown;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(readFileSync(join(dir, PROJECT_REGISTRY_FILE), "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.projects)) return [];
|
|
35
|
+
const out: ProjectRegistryEntry[] = [];
|
|
36
|
+
for (const entry of parsed.projects) {
|
|
37
|
+
if (!isRecord(entry) || typeof entry.root !== "string" || entry.root.length === 0) continue;
|
|
38
|
+
out.push({ root: entry.root, lastUsed: typeof entry.lastUsed === "string" ? entry.lastUsed : "" });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Atomic replace (temp + rename) so concurrent writers cannot corrupt the file. */
|
|
44
|
+
function writeProjectRegistry(entries: ProjectRegistryEntry[], dir: string): void {
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
const path = join(dir, PROJECT_REGISTRY_FILE);
|
|
47
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
48
|
+
writeFileSync(tmp, `${JSON.stringify({ projects: entries }, null, 2)}\n`);
|
|
49
|
+
renameSync(tmp, path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Upsert `root` (stamped now) and prune entries whose project lock file is gone.
|
|
54
|
+
* Best-effort: a write failure is logged, never thrown — the registry is a
|
|
55
|
+
* convenience index, not a run prerequisite.
|
|
56
|
+
*/
|
|
57
|
+
export function registerProject(root: string, dir = agentDir()): void {
|
|
58
|
+
try {
|
|
59
|
+
const kept = readProjectRegistry(dir).filter((entry) => entry.root !== root && existsSync(projectLockPath(entry.root)));
|
|
60
|
+
kept.push({ root, lastUsed: new Date().toISOString() });
|
|
61
|
+
writeProjectRegistry(kept, dir);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`omp-llm-role: could not update the project registry: ${err instanceof Error ? err.message : err}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/project-roles` core: a discovered project profile -> a project-scoped role
|
|
3
|
+
* set (kept shipped roles, dropped ones, new fitted roles) written to the
|
|
4
|
+
* project plugin settings lock file, plus the project agents for the new roles.
|
|
5
|
+
*
|
|
6
|
+
* Dual-runtime safe: `node:` builtins + relative `.ts` imports only, no SDK
|
|
7
|
+
* import. The extension command does the LLM work (profile discovery, benchmark
|
|
8
|
+
* discovery, agent authoring) and calls `setupProject`; the project-aware
|
|
9
|
+
* updater (a later task) writes the project `modelRoles`.
|
|
10
|
+
*
|
|
11
|
+
* All-or-nothing: every check (names, metrics, weight math, the force gate, the
|
|
12
|
+
* agent-file collisions) runs before any write, so a rejected plan leaves the
|
|
13
|
+
* lock file and the agents dir untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { AGENT_NAME_RE, RESERVED_AGENT_NAMES, projectAgentsDir } from "./agent-file.ts";
|
|
19
|
+
import {
|
|
20
|
+
applyFocusBenchmarks,
|
|
21
|
+
checkWeightMath,
|
|
22
|
+
createAgent,
|
|
23
|
+
resolveRole,
|
|
24
|
+
tokenizeArgs,
|
|
25
|
+
type AgentSpec,
|
|
26
|
+
type CreateAgentRequest,
|
|
27
|
+
type FocusCoverage,
|
|
28
|
+
} from "./agent-create.ts";
|
|
29
|
+
import type { RoleDef, SuffixLevel } from "./engine.ts";
|
|
30
|
+
import { FEATURES, featureById } from "./features.ts";
|
|
31
|
+
import { isRecord } from "./guards.ts";
|
|
32
|
+
import { validateRole, writeRoleSettings } from "./role-settings.ts";
|
|
33
|
+
import { DEFAULT_ROLES, isKnownMetric, readPluginSettingsMap, resolveSettings, roleUniverse } from "./settings.ts";
|
|
34
|
+
|
|
35
|
+
/** One role the profile proposes: a shipped role to keep/drop, or a new role to fit. */
|
|
36
|
+
export type ProposedRole = {
|
|
37
|
+
name: string;
|
|
38
|
+
purpose: string;
|
|
39
|
+
/** For a shipped role: keep (default) or drop. A new role with `keep: false` is not added. */
|
|
40
|
+
keep?: boolean;
|
|
41
|
+
/** Metric names the new role is ranked on (links resolved by the extension). */
|
|
42
|
+
benchmarks?: string[];
|
|
43
|
+
thinking?: SuffixLevel;
|
|
44
|
+
tools?: string[];
|
|
45
|
+
/** The architect's output, when the extension ran it. */
|
|
46
|
+
spec?: AgentSpec;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The discovered project usecase. */
|
|
50
|
+
export type ProjectProfile = {
|
|
51
|
+
summary: string;
|
|
52
|
+
domain: string;
|
|
53
|
+
primaryWork: string[];
|
|
54
|
+
stack: string[];
|
|
55
|
+
needs: string[];
|
|
56
|
+
roles: ProposedRole[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type SetupProjectOpts = {
|
|
60
|
+
/** omp's project dir: `<cwd>/.omp`. */
|
|
61
|
+
projectDir: string;
|
|
62
|
+
/** The project plugin settings lock file (`<projectDir>/plugins/omp-plugins.lock.json`). */
|
|
63
|
+
lockPath: string;
|
|
64
|
+
/** The project `config.yml` (`<projectDir>/config.yml`); written by the project-aware updater. */
|
|
65
|
+
configPath: string;
|
|
66
|
+
dryRun: boolean;
|
|
67
|
+
force: boolean;
|
|
68
|
+
yes: boolean;
|
|
69
|
+
coverage?: FocusCoverage;
|
|
70
|
+
/**
|
|
71
|
+
* Capability flags to author on every new project role (`--feature`). Passed
|
|
72
|
+
* through to each planned request, so `resolveRole` sets `def.features` and
|
|
73
|
+
* the flag is persisted (not the knobs it expands to).
|
|
74
|
+
*/
|
|
75
|
+
features?: Record<string, boolean>;
|
|
76
|
+
/** Role validator; defaults to the plugin's own `validateRole`. */
|
|
77
|
+
validate?: (name: string, def: RoleDef) => string[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type SetupProjectResult =
|
|
81
|
+
| {
|
|
82
|
+
ok: true;
|
|
83
|
+
kept: string[];
|
|
84
|
+
dropped: string[];
|
|
85
|
+
added: string[];
|
|
86
|
+
/** The project's effective role set (kept shipped defs + new defs). */
|
|
87
|
+
roles: Record<string, RoleDef>;
|
|
88
|
+
/** Archetype id each new role was fitted to, keyed by role name. */
|
|
89
|
+
archetypes: Record<string, string>;
|
|
90
|
+
/** The project agent `.md` paths (planned on a dry run, written otherwise). */
|
|
91
|
+
agents: string[];
|
|
92
|
+
warnings: string[];
|
|
93
|
+
}
|
|
94
|
+
| { ok: false; errors: string[] };
|
|
95
|
+
|
|
96
|
+
/** True when the project lock file already carries a project role config. */
|
|
97
|
+
function hasProjectRoleConfig(lockPath: string): boolean {
|
|
98
|
+
const raw = readPluginSettingsMap({ global: lockPath, project: null });
|
|
99
|
+
const nested = raw.roles;
|
|
100
|
+
if (isRecord(nested) && Object.keys(nested).length > 0) return true;
|
|
101
|
+
return Object.keys(raw).some((key) => key.startsWith("roles."));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function setupProject(profile: ProjectProfile, opts: SetupProjectOpts): SetupProjectResult {
|
|
105
|
+
const errors: string[] = [];
|
|
106
|
+
const warnings: string[] = [];
|
|
107
|
+
|
|
108
|
+
// 1. Validate the profile's role names.
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
for (const role of profile.roles) {
|
|
111
|
+
const name = role.name.trim();
|
|
112
|
+
if (!AGENT_NAME_RE.test(name)) errors.push(`role "${name}" must match [A-Za-z0-9_-]+`);
|
|
113
|
+
else if (name.toLowerCase() in RESERVED_AGENT_NAMES) errors.push(`role "${name}" is reserved by omp (main/sub are session sentinels)`);
|
|
114
|
+
if (seen.has(name)) errors.push(`role "${name}" is listed twice`);
|
|
115
|
+
seen.add(name);
|
|
116
|
+
}
|
|
117
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
118
|
+
|
|
119
|
+
// 2. Resolve the role set: shipped roles default to keep, `keep: false` drops
|
|
120
|
+
// one, every other name is a new role fitted from the archetype table.
|
|
121
|
+
const wanted = new Set(Object.keys(DEFAULT_ROLES));
|
|
122
|
+
const dropped: string[] = [];
|
|
123
|
+
const newRoles: ProposedRole[] = [];
|
|
124
|
+
for (const role of profile.roles) {
|
|
125
|
+
const name = role.name.trim();
|
|
126
|
+
const shipped = name in DEFAULT_ROLES;
|
|
127
|
+
if (role.keep === false) {
|
|
128
|
+
if (shipped) {
|
|
129
|
+
wanted.delete(name);
|
|
130
|
+
dropped.push(name);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (shipped) continue;
|
|
135
|
+
wanted.add(name);
|
|
136
|
+
newRoles.push(role);
|
|
137
|
+
}
|
|
138
|
+
const kept = Object.keys(DEFAULT_ROLES).filter((name) => wanted.has(name));
|
|
139
|
+
|
|
140
|
+
// Fit and validate each new role's weights.
|
|
141
|
+
const validate = opts.validate ?? validateRole;
|
|
142
|
+
const plans: { name: string; request: CreateAgentRequest; def: RoleDef; archetype: string }[] = [];
|
|
143
|
+
for (const role of newRoles) {
|
|
144
|
+
const name = role.name.trim();
|
|
145
|
+
if (role.purpose.trim() === "") {
|
|
146
|
+
errors.push(`role ${name}: purpose is required`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const metric of role.benchmarks ?? []) {
|
|
150
|
+
if (!isKnownMetric(metric)) errors.push(`role ${name}: unknown metric "${metric}"`);
|
|
151
|
+
}
|
|
152
|
+
const request: CreateAgentRequest = {
|
|
153
|
+
name,
|
|
154
|
+
purpose: role.purpose,
|
|
155
|
+
thinking: role.thinking,
|
|
156
|
+
tools: role.tools,
|
|
157
|
+
spec: role.spec,
|
|
158
|
+
features: opts.features,
|
|
159
|
+
extraBenchmarks: role.benchmarks ?? [],
|
|
160
|
+
coverage: opts.coverage,
|
|
161
|
+
scope: "project",
|
|
162
|
+
projectAnchor: dirname(opts.projectDir),
|
|
163
|
+
force: opts.force,
|
|
164
|
+
dryRun: opts.dryRun,
|
|
165
|
+
lockPath: opts.lockPath,
|
|
166
|
+
};
|
|
167
|
+
const resolved = resolveRole(request);
|
|
168
|
+
if ("errors" in resolved) {
|
|
169
|
+
errors.push(...resolved.errors);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const bench = applyFocusBenchmarks(resolved.def.weights, request.extraBenchmarks ?? [], opts.coverage);
|
|
173
|
+
resolved.def.weights = bench.weights;
|
|
174
|
+
errors.push(...checkWeightMath(name, resolved.def.weights, resolved.def.required));
|
|
175
|
+
errors.push(...validate(name, resolved.def));
|
|
176
|
+
if (bench.dropped.length > 0) warnings.push(`role ${name}: focus benchmarks beyond the cap were dropped: ${bench.dropped.join(", ")}`);
|
|
177
|
+
plans.push({ name, request, def: resolved.def, archetype: resolved.archetype.id });
|
|
178
|
+
}
|
|
179
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
180
|
+
|
|
181
|
+
// 3. The force gate and the agent-file collisions: every check before any write.
|
|
182
|
+
if (hasProjectRoleConfig(opts.lockPath) && !opts.force) {
|
|
183
|
+
errors.push(`${opts.lockPath} already has a project role config — pass --force to overwrite`);
|
|
184
|
+
}
|
|
185
|
+
const agentsDir = projectAgentsDir(dirname(opts.projectDir));
|
|
186
|
+
const agentPaths = plans.map((plan) => join(agentsDir, `${plan.name}.md`));
|
|
187
|
+
for (const agentPath of agentPaths) {
|
|
188
|
+
if (existsSync(agentPath) && !opts.force) errors.push(`${agentPath} already exists — pass --force to overwrite`);
|
|
189
|
+
}
|
|
190
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
191
|
+
|
|
192
|
+
// 4. The project role set: `enabled: false` for every universe role the project
|
|
193
|
+
// does not want, plus the new roles' defs.
|
|
194
|
+
const projectRaw = readPluginSettingsMap({ global: opts.lockPath, project: null });
|
|
195
|
+
const { settings: projectResolved } = resolveSettings(projectRaw);
|
|
196
|
+
const universe = roleUniverse(projectRaw, projectResolved.roles);
|
|
197
|
+
const dirty: Record<string, RoleDef> = {};
|
|
198
|
+
for (const name of Object.keys(universe)) {
|
|
199
|
+
if (wanted.has(name)) continue;
|
|
200
|
+
dirty[name] = { ...universe[name].def, enabled: false };
|
|
201
|
+
}
|
|
202
|
+
for (const plan of plans) dirty[plan.name] = plan.def;
|
|
203
|
+
|
|
204
|
+
const roles: Record<string, RoleDef> = {};
|
|
205
|
+
for (const name of kept) roles[name] = DEFAULT_ROLES[name];
|
|
206
|
+
for (const plan of plans) roles[plan.name] = plan.def;
|
|
207
|
+
|
|
208
|
+
const added = plans.map((plan) => plan.name);
|
|
209
|
+
const archetypes: Record<string, string> = {};
|
|
210
|
+
for (const plan of plans) archetypes[plan.name] = plan.archetype;
|
|
211
|
+
if (opts.dryRun) return { ok: true, kept, dropped, added, roles, archetypes, agents: agentPaths, warnings };
|
|
212
|
+
|
|
213
|
+
// 5. Write the project plugin settings, then create the project agents.
|
|
214
|
+
if (Object.keys(dirty).length > 0) {
|
|
215
|
+
const written = writeRoleSettings(opts.lockPath, dirty, { dryRun: false });
|
|
216
|
+
if (!written.ok) return { ok: false, errors: written.errors };
|
|
217
|
+
}
|
|
218
|
+
for (const plan of plans) {
|
|
219
|
+
const result = createAgent({ ...plan.request, dryRun: false });
|
|
220
|
+
if (!result.ok) return { ok: false, errors: result.errors };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { ok: true, kept, dropped, added, roles, archetypes, agents: agentPaths, warnings };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// `/project-roles` argument parsing and the plan report, shared by the omp
|
|
228
|
+
// command (which receives the raw text typed after the command name) and the
|
|
229
|
+
// tests (which pass the same raw text). One parser, so the two cannot drift.
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
export const PROJECT_ROLES_USAGE = [
|
|
233
|
+
"Usage: /project-roles [options]",
|
|
234
|
+
"",
|
|
235
|
+
" Discovers the project's usecase from its own artifacts (README, package.json,",
|
|
236
|
+
" AGENTS.md, docs, git log, file tree), proposes a project-scoped role set, and",
|
|
237
|
+
" applies it: the project plugin settings, the new roles' agents, and the",
|
|
238
|
+
" project's modelRoles in <cwd>/.omp/config.yml.",
|
|
239
|
+
"",
|
|
240
|
+
" --purpose <text> override the discovered usecase (one sentence)",
|
|
241
|
+
" --roles <spec> override the role set: comma-separated entries;",
|
|
242
|
+
" `-name` drops a shipped role, `name` keeps/adds one,",
|
|
243
|
+
" `name=purpose` adds a new role with that purpose",
|
|
244
|
+
" --feature <id,...> capability flag(s) to author on every new project role",
|
|
245
|
+
" (see /create-agent --list-features); repeatable, merged",
|
|
246
|
+
" --force overwrite an existing project role config",
|
|
247
|
+
" --yes apply without prompting",
|
|
248
|
+
" --dry-run print the plan without writing",
|
|
249
|
+
" --json print the result as JSON",
|
|
250
|
+
].join("\n");
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a `--roles` spec: comma-separated entries. `-name` drops a shipped role;
|
|
254
|
+
* `name` keeps a shipped role (or adds a new one, purpose defaulting to the
|
|
255
|
+
* name); `name=purpose` adds a new role with that purpose. Returns the proposed
|
|
256
|
+
* roles, or an error string.
|
|
257
|
+
*/
|
|
258
|
+
export function parseRolesSpec(spec: string): ProposedRole[] | string {
|
|
259
|
+
const roles: ProposedRole[] = [];
|
|
260
|
+
for (const raw of spec.split(",")) {
|
|
261
|
+
const entry = raw.trim();
|
|
262
|
+
if (entry === "") continue;
|
|
263
|
+
if (entry.startsWith("-")) {
|
|
264
|
+
const name = entry.slice(1).trim();
|
|
265
|
+
if (name === "") return `--roles: "-" needs a role name`;
|
|
266
|
+
roles.push({ name, purpose: "", keep: false });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const eq = entry.indexOf("=");
|
|
270
|
+
if (eq === -1) {
|
|
271
|
+
roles.push({ name: entry, purpose: entry in DEFAULT_ROLES ? "" : entry });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const name = entry.slice(0, eq).trim();
|
|
275
|
+
const purpose = entry.slice(eq + 1).trim();
|
|
276
|
+
if (name === "" || purpose === "") return `--roles: expected name=purpose, got "${entry}"`;
|
|
277
|
+
roles.push({ name, purpose });
|
|
278
|
+
}
|
|
279
|
+
if (roles.length === 0) return `--roles: no roles given`;
|
|
280
|
+
return roles;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type ParsedProjectRolesArgs =
|
|
284
|
+
| { ok: true; purpose: string | undefined; roles: ProposedRole[] | undefined; features: Record<string, boolean> | undefined; force: boolean; yes: boolean; dryRun: boolean; json: boolean; help: boolean }
|
|
285
|
+
| { ok: false; error: string };
|
|
286
|
+
|
|
287
|
+
/** Parse the raw text after `/project-roles` (tokenized here, so the command and
|
|
288
|
+
* the tests share one parser). */
|
|
289
|
+
export function parseProjectRolesArgs(raw: string): ParsedProjectRolesArgs {
|
|
290
|
+
const argv = tokenizeArgs(raw);
|
|
291
|
+
let purpose: string | undefined;
|
|
292
|
+
let roles: ProposedRole[] | undefined;
|
|
293
|
+
let features: Record<string, boolean> | undefined;
|
|
294
|
+
let force = false;
|
|
295
|
+
let yes = false;
|
|
296
|
+
let dryRun = false;
|
|
297
|
+
let json = false;
|
|
298
|
+
let help = false;
|
|
299
|
+
for (let i = 0; i < argv.length; i++) {
|
|
300
|
+
const flag = argv[i];
|
|
301
|
+
if (flag === "--help" || flag === "-h") {
|
|
302
|
+
help = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (flag === "--force") {
|
|
306
|
+
force = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (flag === "--yes") {
|
|
310
|
+
yes = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (flag === "--dry-run") {
|
|
314
|
+
dryRun = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (flag === "--json") {
|
|
318
|
+
json = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (flag !== "--purpose" && flag !== "--roles" && flag !== "--feature") return { ok: false, error: `unknown flag "${flag}"\n\n${PROJECT_ROLES_USAGE}` };
|
|
322
|
+
const value = argv[++i];
|
|
323
|
+
if (value === undefined) return { ok: false, error: `${flag} requires a value\n\n${PROJECT_ROLES_USAGE}` };
|
|
324
|
+
if (flag === "--purpose") purpose = value;
|
|
325
|
+
else if (flag === "--feature") {
|
|
326
|
+
const ids = value.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
327
|
+
if (ids.length === 0) return { ok: false, error: `--feature: no capability id given\n\n${PROJECT_ROLES_USAGE}` };
|
|
328
|
+
const merged = features ?? {};
|
|
329
|
+
for (const id of ids) {
|
|
330
|
+
if (featureById(id) === null) {
|
|
331
|
+
return { ok: false, error: `--feature: unknown capability "${id}" — one of ${FEATURES.map((f) => f.id).join(", ")}\n\n${PROJECT_ROLES_USAGE}` };
|
|
332
|
+
}
|
|
333
|
+
merged[id] = true;
|
|
334
|
+
}
|
|
335
|
+
features = merged;
|
|
336
|
+
} else {
|
|
337
|
+
const parsed = parseRolesSpec(value);
|
|
338
|
+
if (typeof parsed === "string") return { ok: false, error: `${parsed}\n\n${PROJECT_ROLES_USAGE}` };
|
|
339
|
+
roles = parsed;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, purpose, roles, features, force, yes, dryRun, json, help };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Apply the `--purpose`/`--roles` overrides to a discovered profile. */
|
|
346
|
+
export function applyProfileOverrides(profile: ProjectProfile, over: { purpose?: string; roles?: ProposedRole[] }): ProjectProfile {
|
|
347
|
+
return {
|
|
348
|
+
...profile,
|
|
349
|
+
...(over.purpose !== undefined ? { summary: over.purpose } : {}),
|
|
350
|
+
...(over.roles !== undefined ? { roles: over.roles } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The human-readable plan for a `/project-roles` run. `topPicks` maps a new
|
|
355
|
+
* role name to its ranked leader (computed by the extension from the ranking
|
|
356
|
+
* universe); `warnings` are the divergence/coverage notes. */
|
|
357
|
+
export function formatProjectRolesReport(
|
|
358
|
+
profile: ProjectProfile,
|
|
359
|
+
result: Extract<SetupProjectResult, { ok: true }>,
|
|
360
|
+
opts: { topPicks?: Record<string, string>; warnings?: readonly string[] } = {},
|
|
361
|
+
): string {
|
|
362
|
+
const lines: string[] = ["project-roles: plan"];
|
|
363
|
+
lines.push(` kept: ${result.kept.join(", ") || "(none)"}`);
|
|
364
|
+
lines.push(` dropped: ${result.dropped.join(", ") || "(none)"}`);
|
|
365
|
+
lines.push(` added: ${result.added.join(", ") || "(none)"}`);
|
|
366
|
+
for (const name of result.added) {
|
|
367
|
+
const def = result.roles[name];
|
|
368
|
+
const proposed = profile.roles.find((role) => role.name.trim() === name);
|
|
369
|
+
const weights = Object.entries(def.weights).map(([metric, weight]) => `${metric}=${weight}`).join(", ");
|
|
370
|
+
const focus = proposed?.benchmarks !== undefined && proposed.benchmarks.length > 0 ? proposed.benchmarks.join(", ") : "(archetype default)";
|
|
371
|
+
const top = opts.topPicks?.[name];
|
|
372
|
+
lines.push(` @${name} [${result.archetypes[name] ?? "?"}]`);
|
|
373
|
+
lines.push(` weights: ${weights}`);
|
|
374
|
+
lines.push(` focus: ${focus}`);
|
|
375
|
+
const features = Object.entries(def.features ?? {});
|
|
376
|
+
if (features.length > 0) lines.push(` features: ${features.map(([id, on]) => `${id}=${on}`).join(",")}`);
|
|
377
|
+
if (top !== undefined) lines.push(` top pick: ${top}`);
|
|
378
|
+
}
|
|
379
|
+
for (const warning of opts.warnings ?? []) lines.push(` warning: ${warning}`);
|
|
380
|
+
for (const warning of result.warnings) lines.push(` warning: ${warning}`);
|
|
381
|
+
return lines.join("\n");
|
|
382
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<!-- vendored from omp 18.4.8 (agent-creation-architect); re-sync on upgrade — see docs/dev/agent-authoring.md -->
|
|
2
|
+
|
|
1
3
|
You: AI agent architect; translate user requirements → precisely tuned agent configurations.
|
|
2
4
|
|
|
3
5
|
Agent creation: consider project-specific `CLAUDE.md` instructions; align new agents with established project patterns.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!-- plugin-authored: the /project-roles profile-discovery architect -->
|
|
2
|
+
|
|
3
|
+
You: project analyst; read a repository's own artifacts and describe its usecase as a model-role set.
|
|
4
|
+
|
|
5
|
+
Given the repository artifacts (README, package.json, AGENTS.md, docs listing, git log, file tree), infer:
|
|
6
|
+
1. The project's one-line usecase (`summary`) and its domain (`domain`, e.g. "data science", "web app", "docs").
|
|
7
|
+
2. Its primary work (`primaryWork`, e.g. "backend API", "data pipelines") and its stack (`stack`, e.g. "Python", "Postgres").
|
|
8
|
+
3. The capabilities its agents need (`needs`): metric names from the plugin's known set where possible (e.g. `math`, `reasoning`, `code`, `writing`, `long_context`, `tool_calling`, `vision`, `agents`, `search`).
|
|
9
|
+
4. The role set (`roles`): the shipped roles to keep or drop, and any new specialist roles.
|
|
10
|
+
|
|
11
|
+
Shipped roles: `default`, `smol`, `slow`, `vision`, `plan`, `commit`, `tiny`, `task`, `advisor`, `designer`. A shipped role is kept by default; set `"keep": false` to drop one the project does not need (e.g. drop `vision` and `designer` for a backend service). A new role is an entry whose name is not a shipped role; give it a one-sentence `purpose` and, when a specific benchmark should drive it, a `benchmarks` list of metric names.
|
|
12
|
+
|
|
13
|
+
Output MUST be a valid JSON object with exactly these fields:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"summary": "A one-line description of the project's usecase",
|
|
18
|
+
"domain": "A short domain label",
|
|
19
|
+
"primaryWork": ["The main kinds of work the project does"],
|
|
20
|
+
"stack": ["The languages, frameworks and services it uses"],
|
|
21
|
+
"needs": ["The capability metrics its agents need"],
|
|
22
|
+
"roles": [
|
|
23
|
+
{ "name": "a shipped role to keep or drop, or a new role name", "purpose": "one sentence; the routing description", "keep": true, "benchmarks": ["metric names"] }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Role names MUST match `[A-Za-z0-9_-]+` and MUST NOT be `main` or `sub`.
|
|
30
|
+
- A new role's `purpose` MUST be one sentence describing the specialist work.
|
|
31
|
+
- Keep the plan minimal: keep the shipped roles that fit, drop only the ones the project does not need, and add only the roles the project's specialist work requires.
|
|
32
|
+
- NEVER invent benchmark metric names; use the plugin's known metrics.
|
|
33
|
+
- `keep` is only meaningful for a shipped role; omit it for a new role.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider display: keep the live session model's `name` suffixed with its
|
|
3
|
+
* upstream provider pin, so omp's model segment renders e.g.
|
|
4
|
+
* `DeepSeek V4.1 Flash @coreweave/fp8`.
|
|
5
|
+
*
|
|
6
|
+
* omp's built-in model segment renders `state.model.name || state.model.id`
|
|
7
|
+
* only and exposes no extension hook to add to it; the only lever is the live
|
|
8
|
+
* model object's `name` (the exact object `ctx.models.current()` returns). This
|
|
9
|
+
* module is pure and dual-runtime (no `@oh-my-pi/*` import) — the extension
|
|
10
|
+
* wiring only calls it.
|
|
11
|
+
*/
|
|
12
|
+
import { splitThinkingSuffix } from "./availability.ts";
|
|
13
|
+
import { isRecord } from "./guards.ts";
|
|
14
|
+
|
|
15
|
+
/** The single upstream route of a compat routing block, or null. Mirrors omp's
|
|
16
|
+
* `getSingleRoutingOnly`: exactly one entry, a non-empty string. */
|
|
17
|
+
function singleOnly(routing: unknown): string | null {
|
|
18
|
+
if (!isRecord(routing) || !Array.isArray(routing.only) || routing.only.length !== 1) return null;
|
|
19
|
+
const upstream = routing.only[0];
|
|
20
|
+
return typeof upstream === "string" && upstream ? upstream : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The provider pin carried by a model's compat routing block:
|
|
25
|
+
* `compat.openRouterRouting.only[0]`, else `compat.vercelGatewayRouting.only[0]`.
|
|
26
|
+
* Null when absent, when `only` is not exactly one entry, or when the entry is
|
|
27
|
+
* not a non-empty string.
|
|
28
|
+
*/
|
|
29
|
+
export function providerPinOf(model: unknown): string | null {
|
|
30
|
+
if (!isRecord(model) || !isRecord(model.compat)) return null;
|
|
31
|
+
const compat = model.compat;
|
|
32
|
+
return singleOnly(compat.openRouterRouting) ?? singleOnly(compat.vercelGatewayRouting);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The trailing `@<slug>` of a selector (`openrouter/<id>@<slug>:<level>`),
|
|
37
|
+
* after stripping a trailing thinking suffix (`:high`, `:auto`, …). Null when
|
|
38
|
+
* there is no `@` or the slug is empty. Strip order mirrors `currentRankingId`
|
|
39
|
+
* in `src/availability.ts`: level first, then `@`.
|
|
40
|
+
*/
|
|
41
|
+
export function providerPinOfSelector(selector: string): string | null {
|
|
42
|
+
const { base } = splitThinkingSuffix(selector);
|
|
43
|
+
const at = base.lastIndexOf("@");
|
|
44
|
+
if (at === -1) return null;
|
|
45
|
+
const slug = base.slice(at + 1);
|
|
46
|
+
return slug || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip any previous ` @<slug>` suffix from `name`, then append ` @<pin>` when
|
|
51
|
+
* `pin` is non-null. Idempotent; a null pin returns the base name (suffix
|
|
52
|
+
* stripped). Plain text only — no theme tokens, no ANSI.
|
|
53
|
+
*/
|
|
54
|
+
export function withProviderSuffix(name: string, pin: string | null): string {
|
|
55
|
+
const base = name.replace(/\s@\S+$/, "");
|
|
56
|
+
return pin === null ? base : `${base} @${pin}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set `model.name` to `withProviderSuffix(model.name, pin)` when `model` is a
|
|
61
|
+
* record with a string `name`; a no-op otherwise.
|
|
62
|
+
*/
|
|
63
|
+
export function applyProviderDisplay(model: unknown, pin: string | null): void {
|
|
64
|
+
if (isRecord(model) && typeof model.name === "string") {
|
|
65
|
+
model.name = withProviderSuffix(model.name, pin);
|
|
66
|
+
}
|
|
67
|
+
}
|