From 3b5e68e47b24213f482e3468873b28635f686a96 Mon Sep 17 00:00:00 2001 From: Yehonal <147092+Yehonal@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:58:27 +0000 Subject: [PATCH] feat(openpack): support suggested companion artifacts --- README.md | 30 ++++++- docs/spec/openpack.md | 15 ++++ skills/agentwheel/SKILL.md | 13 +++ src/cli/index.ts | 47 +++++++++++ src/lifecycle/profile.ts | 13 +++ src/lifecycle/source-plan.ts | 4 + src/model/artifact.ts | 16 ++++ src/model/package-validate.ts | 17 ++++ src/model/package.ts | 9 +- src/model/workspace.ts | 2 + src/resolve/graph.ts | 147 ++++++++++++++++++++++++++++++++- src/source/local.ts | 8 +- test/cli-verb-redesign.test.ts | 46 +++++++++++ test/openpack-package.test.ts | 15 ++++ test/resolve-graph.test.ts | 51 ++++++++++++ 15 files changed, 426 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 56a38aa..68cb7f7 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,17 @@ be deselected: } ``` +Package authors can also declare suggested companion packages. Suggestions are not installed by +default; users opt in with `--with-suggestions` for all suggestions relevant to selected artifacts, +or `--suggestion ` for one named suggestion. The choice is saved when used with `add` or +`install `. + +```bash +agentwheel add github:your-org/agent-pack --skill triage --with-suggestions --adapter codex --local +agentwheel plan --skill triage --with-suggestions +agentwheel install github:your-org/agent-pack --skill triage --suggestion brainstorming --adapter codex --local +``` + ## Dependencies And Composition OpenPack packages can depend on other packages and compose shared markdown fragments: @@ -346,9 +357,22 @@ OpenPack packages can depend on other packages and compose shared markdown fragm "select": ["rules/safe-actions.md", "fragments/risk.md"] } }, + "suggests": { + "brainstorming": { + "source": "vercel:skills.sh/example/agent-skills", + "select": ["skills/brainstorming"], + "reason": "Generate options before converging." + } + }, "provides": [ { "type": "fragments", "path": "fragments" }, - { "type": "skills", "path": "skills" } + { + "type": "skills", + "path": "skills", + "items": { + "triage": { "suggests": ["brainstorming"] } + } + } ] } ``` @@ -362,6 +386,10 @@ OpenPack packages can depend on other packages and compose shared markdown fragm `core:fragments/risk.md`. - **Trust.** New transitive sources prompt before install. Pre-approve with `--trust ` or `--yes`, set a workspace trust policy, and manage persisted decisions with `agentwheel trust`. +- **Suggested companions.** `suggests` uses the same source/selection shape as `requires`, but it + is opt-in. `--with-suggestions` pulls all relevant suggestions as non-blocking optional edges; + `--suggestion ` pulls a specific suggestion and fails if that explicit suggestion cannot + resolve. - **Offline & frozen installs.** `--offline` guarantees zero network; `--frozen-lock` hard-fails if resolution would differ from the lock. - **Introspection.** `agentwheel deps tree` prints the resolved graph; `agentwheel deps why diff --git a/docs/spec/openpack.md b/docs/spec/openpack.md index bc4f7be..26e68e8 100644 --- a/docs/spec/openpack.md +++ b/docs/spec/openpack.md @@ -35,6 +35,14 @@ composition metadata, runtime targeting, or fragments. "runtimes": ["claude"] } }, + "suggests": { + "brainstorming": { + "source": "vercel:skills.sh/example/agent-skills", + "select": ["skills/brainstorming"], + "reason": "Generate candidate approaches before converging.", + "when": "Use when the selected skill needs divergent ideation first." + } + }, "provides": [ { "type": "fragments", "path": "fragments" }, { "type": "rules", "path": "rules/safe-actions.md", "format": "markdown-rule", "runtimes": ["claude", "copilot"] }, @@ -45,6 +53,7 @@ composition metadata, runtime targeting, or fragments. "items": { "triage": { "requires": ["rules/safe-actions.md"], + "suggests": ["brainstorming"], "compose": [ { "include": "fragments/review-style.md" }, { "include": "fragments/local-note.md", "optional": true, "markers": false } @@ -62,6 +71,12 @@ L4 resolve the dependency graph recursively, apply parseable semver ranges, and source identities. Tools below L4 may still validate and record these fields without resolving them. +Suggestion declarations in `suggests` share the dependency source shape, plus optional `reason` +and `when` text for humans and catalogues. Suggestions are not dependency requirements: an L4 +installer MUST ignore them unless the user or workspace explicitly requests them. When requested +as a set, suggestions SHOULD be treated as non-blocking optional edges; when a specific alias is +requested, failure SHOULD be reported unless the suggestion declares `optional: true`. + ### Meta-packages An OpenPack v2 manifest MAY omit `provides` or declare it empty when it declares at least one diff --git a/skills/agentwheel/SKILL.md b/skills/agentwheel/SKILL.md index 8fed4d1..ea9302f 100644 --- a/skills/agentwheel/SKILL.md +++ b/skills/agentwheel/SKILL.md @@ -145,6 +145,19 @@ agentwheel add github:owner/repo --select skills/code-review --select rules/core agentwheel add github:owner/repo --select skills/code-review,rules/core.md ``` +Install or persist suggested companion artifacts only when requested: + +```bash +agentwheel plan github:owner/repo --skill review --with-suggestions +agentwheel install github:owner/repo --skill review --suggestion brainstorming +agentwheel add github:owner/repo --skill review --with-suggestions --adapter codex --installation-type local +``` + +Treat OpenPack `suggests` as opt-in soft composition, not as `requires.optional`. +`--with-suggestions` includes all suggestions relevant to selected artifacts as non-blocking +optional graph edges. `--suggestion ` includes one named suggestion and should fail if that +explicit suggestion cannot resolve. + Use a source override when a selected package should replace the same artifact coming from another source, such as a forked skill overriding a meta-pack dependency. The declaration is explicit and planning fails if it does not match exactly one losing artifact and one selected replacement: diff --git a/src/cli/index.ts b/src/cli/index.ts index 3556778..d84d30e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -102,6 +102,8 @@ program .option("--name ", "package alias") .option("--select ", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots on future installs", false) + .option("--suggestion ", "include one suggested companion alias on future installs (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--override ", "allow this package to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[]) .action(async (source, options) => { const normalizedOptions = normalizeRuntimeScopeOptions(options); @@ -172,6 +174,8 @@ program .option("--mode ", "pinned or tracking") .option("--select ", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots", false) + .option("--suggestion ", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--override ", "for source previews, allow the source to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[]) .option("--dry-run", "accepted for symmetry; plan never writes", false) .option("--force-drift", "replace drifted managed artifacts during install planning", false) @@ -206,6 +210,8 @@ program .option("--mode ", "pinned or tracking") .option("--select ", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots", false) + .option("--suggestion ", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--override ", "when adding a source, allow it to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[]) .option("--profile ", "workspace runtime profile") .option("--dry-run", "show plan without writing", false) @@ -242,6 +248,8 @@ program .option("--mode ", "pinned or tracking") .option("--select ", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots", false) + .option("--suggestion ", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--override ", "when adding a source, allow it to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[]) .option("--profile ", "workspace runtime profile") .option("--dry-run", "show plan without writing", false) @@ -280,6 +288,8 @@ program .option("--allow-adapter-code", "allow loading local adapter code from configured packages", false) .option("--select ", "temporarily select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "temporarily select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots", false) + .option("--suggestion ", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--no-deps", "resolve only root sources and ignore requires with a warning") .option("--frozen-lock", "resolve strictly from the existing graph lock and cached sources", false) .option("--offline", "resolve strictly from graph locks and local caches", false) @@ -313,6 +323,8 @@ program .option("--mode ", "pinned or tracking") .option("--select ", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[]) .option("--skill ", "select a skill by name (repeatable or comma-separated)", collectSkillOption, [] as string[]) + .option("--with-suggestions", "include suggested companion artifacts for selected roots", false) + .option("--suggestion ", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[]) .option("--no-deps", "resolve only root sources and ignore requires with a warning") .option("--frozen-lock", "resolve strictly from the existing graph lock and cached sources", false) .option("--offline", "resolve strictly from graph locks and local caches", false) @@ -646,6 +658,8 @@ async function runInstallCommand( forceConflict: normalizedOptions.forceConflict, replaceConflict: normalizedOptions.replaceConflict, noDeps: noDepsFromOptions(normalizedOptions), + includeSuggestions: normalizedOptions.withSuggestions, + suggestionAliases: suggestionAliasesFromOptions(normalizedOptions), lockedResolution: true, frozenLock: normalizedOptions.frozenLock, offline: normalizedOptions.offline, @@ -722,6 +736,9 @@ async function packageEntryFromSource( select?: string[]; skill?: string[]; skills?: string[]; + withSuggestions?: boolean; + suggestion?: string[]; + suggestions?: string[]; override?: string[]; overrides?: string[]; frozenLock?: boolean; @@ -765,6 +782,8 @@ async function packageEntryFromSource( mode: options.mode ?? "pinned", requestedRef: bundle.source.requestedRef, select: selectedArtifacts, + withSuggestions: options.withSuggestions === true ? true : undefined, + suggestions: suggestionAliasesFromOptions(options), overrides: overrideArtifactsFromOptions(options), }; } finally { @@ -944,6 +963,9 @@ interface GraphCliOptions { overrides?: string[]; noDeps?: boolean; deps?: boolean; + withSuggestions?: boolean; + suggestion?: string[]; + suggestions?: string[]; onlySource?: boolean; frozenLock?: boolean; offline?: boolean; @@ -1066,6 +1088,8 @@ async function buildGraphPlansForTarget( select: selectedArtifacts && packageIsScoped ? selectedArtifacts : normalizeArtifactSelectors(pkg.select, pkg.skills), aliases: pkg.aliases, overrides: pkg.overrides, + includeSuggestions: targetOptions.withSuggestions === true || pkg.withSuggestions === true, + suggestionAliases: packageSuggestionAliases(pkg, targetOptions), useLock: behavior.mode === "install" ? true : !updateThisPackage, }; }), @@ -1090,6 +1114,8 @@ async function buildGraphPlansForTarget( targetFingerprintParts: targetFingerprintParts(group.target, adapter, group.adapterOptions, group.installationType), installationType: group.installationType, noDeps: noDepsFromOptions(targetOptions), + includeSuggestions: targetOptions.withSuggestions, + suggestionAliases: suggestionAliasesFromOptions(targetOptions), lockedResolution: behavior.mode === "install", frozenLock: targetOptions.frozenLock, offline: targetOptions.offline, @@ -1285,6 +1311,8 @@ async function uninstallConfiguredPackage(target: RuntimeTarget, packageName: st select: normalizeArtifactSelectors(pkg.select, pkg.skills), aliases: pkg.aliases, overrides: pkg.overrides, + includeSuggestions: options.withSuggestions === true || pkg.withSuggestions === true, + suggestionAliases: packageSuggestionAliases(pkg, options), })), targetRoot: remainingGroup.target.targetRoot, workspaceRoot: remainingGroup.target.workspaceRoot, @@ -1293,6 +1321,8 @@ async function uninstallConfiguredPackage(target: RuntimeTarget, packageName: st targetKey: targetKeyForTarget(remainingGroup.target, remainingAdapter.name), targetFingerprintParts: targetFingerprintParts(remainingGroup.target, remainingAdapter, remainingGroup.adapterOptions, remainingGroup.installationType), installationType: remainingGroup.installationType, + includeSuggestions: options.withSuggestions, + suggestionAliases: suggestionAliasesFromOptions(options), lockedResolution: true, frozenLock: options.frozenLock, offline: options.offline, @@ -1661,6 +1691,10 @@ function collectSkillOption(value: string, previous: string[]): string[] { return [...previous, ...splitSelectorList(value)]; } +function collectSuggestionOption(value: string, previous: string[]): string[] { + return [...previous, ...splitSelectorList(value)]; +} + function collectTrustOption(value: string, previous: string[]): string[] { return [...previous, value]; } @@ -1766,6 +1800,19 @@ function selectedArtifactsFromOptions(options: { select?: string[]; skill?: stri return normalizeArtifactSelectors(options.select, options.skills ?? options.skill); } +function suggestionAliasesFromOptions(options: { suggestion?: string[]; suggestions?: string[] }): string[] | undefined { + const values = options.suggestions ?? options.suggestion; + if (!values?.length) return undefined; + return [...new Set(values.flatMap(splitSelectorList).map((item) => item.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} + +function packageSuggestionAliases(pkg: WorkspacePackage, options: { suggestion?: string[]; suggestions?: string[] }): string[] | undefined { + const aliases = [...(pkg.suggestions ?? []), ...(suggestionAliasesFromOptions(options) ?? [])] + .map((item) => item.trim()) + .filter(Boolean); + return aliases.length > 0 ? [...new Set(aliases)].sort((a, b) => a.localeCompare(b)) : undefined; +} + function overrideArtifactsFromOptions(options: { override?: string[]; overrides?: string[] }): string[] | undefined { const values = options.overrides ?? options.override; return values && values.length > 0 ? values : undefined; diff --git a/src/lifecycle/profile.ts b/src/lifecycle/profile.ts index 8b5fe2f..3ded809 100644 --- a/src/lifecycle/profile.ts +++ b/src/lifecycle/profile.ts @@ -28,6 +28,8 @@ export interface ProfileSyncOptions { forceConflict?: boolean; replaceConflict?: boolean; noDeps?: boolean; + includeSuggestions?: boolean; + suggestionAliases?: string[]; lockedResolution?: boolean; frozenLock?: boolean; offline?: boolean; @@ -84,6 +86,8 @@ export async function syncProfile(options: ProfileSyncOptions): Promise item.trim()).filter(Boolean); + return aliases.length > 0 ? [...new Set(aliases)].sort((a, b) => a.localeCompare(b)) : undefined; +} diff --git a/src/lifecycle/source-plan.ts b/src/lifecycle/source-plan.ts index 8abdbb9..567d836 100644 --- a/src/lifecycle/source-plan.ts +++ b/src/lifecycle/source-plan.ts @@ -61,6 +61,8 @@ export interface GraphSourcePlanOptions { targetKey?: string; targetFingerprintParts?: unknown; noDeps?: boolean; + includeSuggestions?: boolean; + suggestionAliases?: string[]; lockedResolution?: boolean; frozenLock?: boolean; offline?: boolean; @@ -172,6 +174,8 @@ export async function createGraphSourcePlan(options: GraphSourcePlanOptions): Pr cacheRoot: join(workspaceRoot, ".agentwheel", "cache"), registryClient, noDeps: options.noDeps, + includeSuggestions: options.includeSuggestions, + suggestionAliases: options.suggestionAliases, lockedResolution: options.lockedResolution, frozenLock: lockMode, offline: options.offline, diff --git a/src/model/artifact.ts b/src/model/artifact.ts index 22cbb80..8b4920f 100644 --- a/src/model/artifact.ts +++ b/src/model/artifact.ts @@ -51,6 +51,21 @@ export const packageItemRequireSchema = z.union([ ]); export type PackageItemRequire = z.infer; +export const packageItemSuggestObjectSchema = z.object({ + alias: z.string().min(1), + select: z.array(z.string().min(1)).optional(), + optional: z.boolean().optional(), + runtimes: z.array(z.string().min(1)).optional(), + reason: z.string().min(1).optional(), + when: z.string().min(1).optional(), +}).passthrough(); + +export const packageItemSuggestSchema = z.union([ + z.string().min(1), + packageItemSuggestObjectSchema, +]); +export type PackageItemSuggest = z.infer; + export const composedFromEntrySchema = z.object({ selector: z.string().min(1), hash: z.string().min(16), @@ -71,6 +86,7 @@ export const artifactSchema = z.object({ assets: z.array(packageAssetSchema).optional(), required: z.boolean().optional(), requires: z.array(packageItemRequireSchema).optional(), + suggests: z.array(packageItemSuggestSchema).optional(), compose: z.array(packageComposeEntrySchema).optional(), runtimes: z.array(z.string().min(1)).optional(), composedFrom: z.array(composedFromEntrySchema).optional(), diff --git a/src/model/package-validate.ts b/src/model/package-validate.ts index 4737b9b..413cfc8 100644 --- a/src/model/package-validate.ts +++ b/src/model/package-validate.ts @@ -71,6 +71,14 @@ function validateDeclaredSelectors(manifest: PackageManifest, findings: PackageV validateSelector(selector, `requires.${alias}.select`, findings, manifestPath, { localOnly: true }); } } + for (const [alias, suggestion] of Object.entries(manifest.suggests ?? {})) { + if (!alias.trim()) { + findings.push({ level: "error", message: "Suggestion alias must be non-empty", path: manifestPath }); + } + for (const selector of suggestion.select ?? []) { + validateSelector(selector, `suggests.${alias}.select`, findings, manifestPath, { localOnly: true }); + } + } } for (const [provideIndex, provide] of manifest.provides.entries()) { @@ -80,6 +88,15 @@ function validateDeclaredSelectors(manifest: PackageManifest, findings: PackageV const selector = typeof requirement === "string" ? requirement : requirement.selector; validateSelector(selector, `provides[${provideIndex}].items.${itemName}.requires`, findings, manifestPath, { aliases: manifest.schemaVersion === 2 ? Object.keys(manifest.requires ?? {}) : [] }); } + for (const suggestion of item.suggests ?? []) { + const alias = typeof suggestion === "string" ? suggestion : suggestion.alias; + if (manifest.schemaVersion === 2 && !Object.keys(manifest.suggests ?? {}).includes(alias)) { + findings.push({ level: "error", message: `provides[${provideIndex}].items.${itemName}.suggests: suggestion alias not declared: ${alias}`, path: manifestPath }); + } + for (const selector of typeof suggestion === "string" ? [] : suggestion.select ?? []) { + validateSelector(selector, `provides[${provideIndex}].items.${itemName}.suggests.${alias}.select`, findings, manifestPath, { localOnly: true }); + } + } for (const entry of item.compose ?? []) { validateSelector(entry.include, `provides[${provideIndex}].items.${itemName}.compose.include`, findings, manifestPath, { aliases: manifest.schemaVersion === 2 ? Object.keys(manifest.requires ?? {}) : [], diff --git a/src/model/package.ts b/src/model/package.ts index 556a902..d893195 100644 --- a/src/model/package.ts +++ b/src/model/package.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { parse, printParseErrorCode, type ParseError } from "jsonc-parser"; import { z } from "zod"; -import { artifactFormatSchema, artifactTypeSchema, packageAssetSchema, packageComposeEntrySchema, packageItemRequireSchema } from "./artifact.js"; +import { artifactFormatSchema, artifactTypeSchema, packageAssetSchema, packageComposeEntrySchema, packageItemRequireSchema, packageItemSuggestSchema } from "./artifact.js"; import { pathExists } from "../utils/fs.js"; const legacyArtifactTypeSchema = z.enum([ @@ -31,6 +31,7 @@ export { packageItemRequireObjectSchema, packageItemRequireSchema } from "./arti export const packageItemSchema = z.object({ format: artifactFormatSchema.optional(), requires: z.array(packageItemRequireSchema).optional(), + suggests: z.array(packageItemSuggestSchema).optional(), compose: z.array(packageComposeEntrySchema).optional(), runtimes: runtimeListSchema.optional(), }); @@ -46,6 +47,11 @@ export const packageDependencySchema = z.object({ runtimes: runtimeListSchema.optional(), }); +export const packageSuggestionSchema = packageDependencySchema.extend({ + reason: z.string().min(1).optional(), + when: z.string().min(1).optional(), +}); + export const packageProvideV1Schema = packageProvideBaseSchema.extend({ type: legacyArtifactTypeSchema, }); @@ -69,6 +75,7 @@ export const packageManifestV2Schema = z.object({ version: z.string().min(1), runtimes: runtimeListSchema.optional(), requires: z.record(z.string().min(1), packageDependencySchema).optional(), + suggests: z.record(z.string().min(1), packageSuggestionSchema).optional(), compose: z.array(packageComposeEntrySchema).optional(), provides: z.array(packageProvideSchema).default([]), }).superRefine((manifest, ctx) => { diff --git a/src/model/workspace.ts b/src/model/workspace.ts index e73fc23..7affb60 100644 --- a/src/model/workspace.ts +++ b/src/model/workspace.ts @@ -19,6 +19,8 @@ export const workspacePackageSchema = z.object({ requestedRef: z.string().min(1).optional(), select: z.array(z.string().min(1)).optional(), skills: z.array(z.string().min(1)).optional(), + withSuggestions: z.boolean().optional(), + suggestions: z.array(z.string().min(1)).optional(), aliases: z.record(z.string(), z.string().min(1)).optional(), overrides: z.array(z.string().min(1)).optional(), }); diff --git a/src/resolve/graph.ts b/src/resolve/graph.ts index 8ecbccc..0d30d83 100644 --- a/src/resolve/graph.ts +++ b/src/resolve/graph.ts @@ -3,7 +3,7 @@ import { mkdtemp, readdir, readFile, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, extname, join } from "node:path"; import { extractOpenPackIncludeSelectors, parseOpenPackIncludeSelector } from "../compose/markdown.js"; -import type { Artifact, PackageItemRequire } from "../model/artifact.js"; +import type { Artifact, PackageItemRequire, PackageItemSuggest } from "../model/artifact.js"; import { artifactTypeSchema } from "../model/artifact.js"; import type { GraphNodeId, ResolvedNode } from "../model/graph.js"; import type { GraphLock, GraphLockArtifact, GraphLockEdge, GraphLockIncludeEdge, GraphLockNamespacing, GraphLockNode, GraphLockOverride, GraphLockRoot } from "../model/graph-lock.js"; @@ -26,6 +26,8 @@ export interface GraphRootRequest { aliases?: Record; overrides?: string[]; useLock?: boolean; + includeSuggestions?: boolean; + suggestionAliases?: string[]; } export interface ResolveGraphOptions { @@ -41,6 +43,8 @@ export interface ResolveGraphOptions { previousLock?: GraphLock; warn?: (message: string) => void; runtime?: string; + includeSuggestions?: boolean; + suggestionAliases?: string[]; } export interface ResolvedGraphRoot { @@ -90,11 +94,15 @@ interface Requirement { integrity?: string; selectionReason?: string; useLock?: boolean; + includeSuggestions?: boolean; + suggestionAliases?: string[]; } type PackageManifestV2 = Extract; type PackageDependencies = NonNullable; type PackageDependency = PackageDependencies[string]; +type PackageSuggestions = NonNullable; +type PackageSuggestion = PackageSuggestions[string]; interface FetchedPackage { normalized: NormalizedDependencySource; @@ -122,8 +130,11 @@ interface NodeState { selectionReasons: Map>; processedNeeds: Set; processedPackageAliases: Set; + processedSuggestions: Set; depth: number; fullPackageSelected: boolean; + includeSuggestions: boolean; + suggestionAliases: Set; } const cacheLocks = new Map>(); @@ -155,6 +166,8 @@ export async function resolveDependencyGraph( depth: 0, optional: false, chain: [`workspace:${rootId}`], + includeSuggestions: root.includeSuggestions ?? options.includeSuggestions, + suggestionAliases: sortedUnique([...(root.suggestionAliases ?? []), ...(options.suggestionAliases ?? [])]), }; }); @@ -308,14 +321,19 @@ async function processRequirement( selectionReasons: new Map(), processedNeeds: new Set(), processedPackageAliases: new Set(), + processedSuggestions: new Set(), depth: requirement.depth, fullPackageSelected: false, + includeSuggestions: false, + suggestionAliases: new Set(), }; nodesByKey.set(nodeKey, state); } state.depth = Math.min(state.depth, requirement.depth); state.fullPackageSelected = state.fullPackageSelected || requirement.select === undefined; + state.includeSuggestions = state.includeSuggestions || requirement.includeSuggestions === true; + for (const alias of requirement.suggestionAliases ?? []) state.suggestionAliases.add(alias); state.requiredBy.add(requirement.requiredBy); for (const selector of selected) addSelectedSelector(state, selector, requirement.selectionReason); refreshNode(state); @@ -371,11 +389,14 @@ async function collectDependencyNeeds( if (fetched.manifest?.schemaVersion !== 2) return []; const dependencies = fetched.manifest.requires ?? {}; + const suggestions = fetched.manifest.suggests ?? {}; const dependencyEntries = Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)); + const suggestionEntries = Object.entries(suggestions).sort(([a], [b]) => a.localeCompare(b)); + const suggestionOptions = suggestionOptionsForState(state, options); const requirements: Requirement[] = []; if (options.noDeps) { - warnNoDepsOnce(state, dependencyEntries.map(([alias]) => alias), options.warn); + warnNoDepsOnce(state, [...dependencyEntries.map(([alias]) => alias), ...suggestionEntries.map(([alias]) => alias)], options.warn); } else { for (const [alias, dependency] of dependencyEntries) { if (state.processedPackageAliases.has(alias)) continue; @@ -384,6 +405,14 @@ async function collectDependencyNeeds( if (!dependencyTargetsRuntime(dependency.runtimes, options.runtime, state.node.id, alias, options.warn)) continue; requirements.push(dependencyRequirement(state, fetched, alias, dependency, dependency.select, chain, options.lockedResolution === true)); } + for (const [alias, suggestion] of suggestionEntries) { + if (state.processedSuggestions.has(alias)) continue; + if (!shouldIncludeSuggestionAlias(alias, suggestionOptions, state.fullPackageSelected)) continue; + if (!suggestion.select?.length && !(state.fullPackageSelected && suggestion.select === undefined) && !explicitSuggestionAlias(alias, suggestionOptions)) continue; + state.processedSuggestions.add(alias); + if (!dependencyTargetsRuntime(suggestion.runtimes, options.runtime, state.node.id, alias, options.warn)) continue; + requirements.push(suggestionRequirement(state, fetched, alias, suggestion, suggestion.select, chain, suggestionOptions)); + } } const artifactsBySelector: Map = new Map(fetched.artifacts.map((artifact) => [artifactSelectorKey(artifact), artifact])); @@ -435,6 +464,29 @@ async function collectDependencyNeeds( addSelectedSelector(state, parsed.selector, `required by ${parentSelector}`); } + for (const suggestion of artifact.suggests ?? []) { + const parsed = parseArtifactSuggestion(suggestion); + if (!shouldIncludeSuggestionAlias(parsed.alias, suggestionOptions, true)) continue; + if (!requirementTargetsRuntime(parsed.runtimes, options.runtime, `${state.node.id}:${parentSelector} -> ${parsed.raw}`, options.warn)) continue; + if (options.noDeps) { + warnNoDepsOnce(state, [parsed.alias], options.warn); + continue; + } + const packageSuggestion = suggestionForAlias(suggestions, state.node.id, parsed.alias); + if (!dependencyTargetsRuntime(packageSuggestion.runtimes, options.runtime, state.node.id, parsed.alias, options.warn)) continue; + requirements.push(suggestionRequirement( + state, + fetched, + parsed.alias, + packageSuggestion, + combinedSelectors(packageSuggestion.select, parsed.select), + chain, + suggestionOptions, + parsed.optional || shouldTreatSuggestionAsOptional(parsed.alias, packageSuggestion, suggestionOptions), + `suggested by ${parentSelector}`, + )); + } + for (const include of await collectIncludeNeeds(artifact, artifactsByRelativePath)) { if (!include.alias) continue; if (options.noDeps) { @@ -489,9 +541,35 @@ function dependencyRequirement( version: dependency.version, integrity: dependency.integrity, selectionReason, + includeSuggestions: state.includeSuggestions, + suggestionAliases: sortedUnique([...state.suggestionAliases]), }; } +function suggestionRequirement( + state: NodeState, + fetched: FetchedPackage, + alias: string, + suggestion: PackageSuggestion, + select: string[] | undefined, + chain: string[], + options: ResolveGraphOptions, + optional = shouldTreatSuggestionAsOptional(alias, suggestion, options), + selectionReason?: string, +): Requirement { + return dependencyRequirement( + state, + fetched, + alias, + suggestion, + select, + chain, + options.lockedResolution === true, + optional, + selectionReason, + ); +} + function dependencyForAlias( dependencies: PackageDependencies, nodeId: string, @@ -504,6 +582,18 @@ function dependencyForAlias( return dependency; } +function suggestionForAlias( + suggestions: PackageSuggestions, + nodeId: string, + alias: string, +): PackageSuggestion { + const suggestion = suggestions[alias]; + if (!suggestion) { + throw new Error(`Suggestion alias not found in ${nodeId}: ${alias}`); + } + return suggestion; +} + function warnNoDepsOnce(state: NodeState, aliases: string[], warn?: (message: string) => void): void { const unique = sortedUnique(aliases.filter(Boolean)); if (unique.length === 0 || state.processedPackageAliases.has("__noDepsWarned")) return; @@ -519,6 +609,14 @@ interface ParsedArtifactRequirement { runtimes?: string[]; } +interface ParsedArtifactSuggestion { + raw: string; + alias: string; + select?: string[]; + optional: boolean; + runtimes?: string[]; +} + function parseArtifactRequirement(requirement: PackageItemRequire): ParsedArtifactRequirement { const raw = typeof requirement === "string" ? requirement : requirement.selector; const parsed = parseDependencySelector(raw); @@ -531,6 +629,51 @@ function parseArtifactRequirement(requirement: PackageItemRequire): ParsedArtifa }; } +function parseArtifactSuggestion(suggestion: PackageItemSuggest): ParsedArtifactSuggestion { + if (typeof suggestion === "string") { + return { + raw: suggestion, + alias: suggestion, + optional: false, + }; + } + return { + raw: suggestion.alias, + alias: suggestion.alias, + select: suggestion.select, + optional: suggestion.optional === true, + runtimes: suggestion.runtimes, + }; +} + +function shouldIncludeSuggestionAlias(alias: string, options: ResolveGraphOptions, includeWhenAllSuggestions: boolean): boolean { + const aliases = new Set(options.suggestionAliases ?? []); + if (aliases.has(alias)) return true; + return options.includeSuggestions === true && includeWhenAllSuggestions; +} + +function suggestionOptionsForState(state: NodeState, options: ResolveGraphOptions): ResolveGraphOptions { + return { + ...options, + includeSuggestions: state.includeSuggestions || options.includeSuggestions === true, + suggestionAliases: sortedUnique([...(options.suggestionAliases ?? []), ...state.suggestionAliases]), + }; +} + +function explicitSuggestionAlias(alias: string, options: ResolveGraphOptions): boolean { + return (options.suggestionAliases ?? []).includes(alias); +} + +function shouldTreatSuggestionAsOptional(alias: string, suggestion: PackageSuggestion, options: ResolveGraphOptions): boolean { + if (suggestion.optional === true) return true; + return options.includeSuggestions === true && !(options.suggestionAliases ?? []).includes(alias); +} + +function combinedSelectors(base: string[] | undefined, extra: string[] | undefined): string[] | undefined { + const values = [...(base ?? []), ...(extra ?? [])]; + return values.length > 0 ? sortedUnique(values) : undefined; +} + function parseDependencySelector(value: string): { alias?: string; selector: string } { const cleaned = value.trim(); const slash = cleaned.indexOf("/"); diff --git a/src/source/local.ts b/src/source/local.ts index de29925..6abefc2 100644 --- a/src/source/local.ts +++ b/src/source/local.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { readdir, stat } from "node:fs/promises"; import { basename, join, relative, resolve } from "node:path"; -import type { Artifact, ArtifactType, PackageComposeEntry, PackageItemRequire } from "../model/artifact.js"; +import type { Artifact, ArtifactType, PackageComposeEntry, PackageItemRequire, PackageItemSuggest } from "../model/artifact.js"; import { findPackageManifestPath, readPackageManifest, type PackageManifest, type PackageProvide } from "../model/package.js"; import { hashPath, pathExists } from "../utils/fs.js"; import type { ResolvedSource, ScanFinding, ScanResult, SourceDriver } from "./types.js"; @@ -260,6 +260,7 @@ async function artifactForFile( assets: provide?.assets, required: provide?.required, requires: item.requires, + suggests: item.suggests, compose: item.compose, runtimes: item.runtimes ?? provideRuntimes(provide) ?? manifestRuntimes(manifest), }; @@ -289,16 +290,17 @@ async function artifactForDir( assets: provide?.assets, required: provide?.required, requires: item.requires, + suggests: item.suggests, compose: item.compose, runtimes: item.runtimes ?? provideRuntimes(provide) ?? manifestRuntimes(manifest), }; } -function itemMetadata(provide: PackageProvide | undefined, itemName: string | undefined): { format?: string; requires?: PackageItemRequire[]; compose?: PackageComposeEntry[]; runtimes?: string[] } { +function itemMetadata(provide: PackageProvide | undefined, itemName: string | undefined): { format?: string; requires?: PackageItemRequire[]; suggests?: PackageItemSuggest[]; compose?: PackageComposeEntry[]; runtimes?: string[] } { if (!provide || !("items" in provide) || !provide.items || !itemName) return {}; const item = provide.items[itemName]; if (!item) return {}; - return { format: item.format, requires: item.requires, compose: item.compose, runtimes: item.runtimes }; + return { format: item.format, requires: item.requires, suggests: item.suggests, compose: item.compose, runtimes: item.runtimes }; } function provideRuntimes(provide: PackageProvide | undefined): string[] | undefined { diff --git a/test/cli-verb-redesign.test.ts b/test/cli-verb-redesign.test.ts index d7e3f58..00cc61b 100644 --- a/test/cli-verb-redesign.test.ts +++ b/test/cli-verb-redesign.test.ts @@ -569,6 +569,52 @@ describe("CLI verb redesign", () => { expect(config.packages.map((pkg: { name: string }) => pkg.name)).toEqual(["agent-core-toolkit-private"]); }); + it("installs suggested companions only when requested and persists that choice", async () => { + const companion = await skillPackageFixture("brainstorming", "Brainstorming"); + const source = await tempRoot("agentwheel-convergent-pack-"); + await mkdir(join(source, "skills", "convergent"), { recursive: true }); + await writeFile( + join(source, "skills", "convergent", "SKILL.md"), + "---\nname: convergent\ndescription: Fixture convergent skill.\n---\n\n# Convergent\n", + "utf8", + ); + await writeFile(join(source, "openpack.json"), `${JSON.stringify({ + schemaVersion: 2, + name: "convergent-pack", + version: "1.0.0", + suggests: { + brainstorm: { + source: companion, + select: ["skills/brainstorming"], + reason: "Generate options before converging.", + }, + }, + provides: [ + { + type: "skills", + path: "skills", + items: { + convergent: { suggests: ["brainstorm"] }, + }, + }, + ], + }, null, 2)}\n`, "utf8"); + + const defaultWorkspace = await tempRoot(); + await runCli(["install", source, "--adapter", "codex", "--installation-type", "local", "--target-root", defaultWorkspace, "--skill", "convergent"]); + await expect(readFile(join(defaultWorkspace, ".agents", "skills", "convergent", "SKILL.md"), "utf8")).resolves.toContain("Convergent"); + await expect(readFile(join(defaultWorkspace, ".agents", "skills", "brainstorming", "SKILL.md"), "utf8")).rejects.toThrow(); + const defaultConfig = JSON.parse(await readFile(join(defaultWorkspace, ".agentwheel", "config.json"), "utf8")); + expect(defaultConfig.packages[0]?.withSuggestions).toBeUndefined(); + + const suggestedWorkspace = await tempRoot(); + await runCli(["install", source, "--adapter", "codex", "--installation-type", "local", "--target-root", suggestedWorkspace, "--skill", "convergent", "--with-suggestions", "--yes"]); + await expect(readFile(join(suggestedWorkspace, ".agents", "skills", "convergent", "SKILL.md"), "utf8")).resolves.toContain("Convergent"); + await expect(readFile(join(suggestedWorkspace, ".agents", "skills", "brainstorming", "SKILL.md"), "utf8")).resolves.toContain("Brainstorming"); + const suggestedConfig = JSON.parse(await readFile(join(suggestedWorkspace, ".agentwheel", "config.json"), "utf8")); + expect(suggestedConfig.packages[0]?.withSuggestions).toBe(true); + }); + it("uninstall --keep-files removes management state but leaves runtime files alone", async () => { const workspace = await tempRoot(); const source = await packageFixture("keep-files"); diff --git a/test/openpack-package.test.ts b/test/openpack-package.test.ts index b2af9d0..63d98a9 100644 --- a/test/openpack-package.test.ts +++ b/test/openpack-package.test.ts @@ -91,6 +91,15 @@ describe("OpenPack package manifests", () => { runtimes: ["claude"], }, }, + suggests: { + brainstorm: { + source: "registry:brainstorm", + select: ["skills/brainstorming"], + reason: "Divergent ideation before converging.", + when: "Use before choosing among underspecified options.", + runtimes: ["codex"], + }, + }, provides: [ { type: "fragments", path: "fragments" }, { @@ -100,6 +109,7 @@ describe("OpenPack package manifests", () => { items: { demo: { requires: ["rules/core.md"], + suggests: ["brainstorm"], compose: [{ include: "fragments/shared.md", markers: false }], runtimes: ["codex"], }, @@ -111,6 +121,8 @@ describe("OpenPack package manifests", () => { const manifest = await readPackageManifest(root); expect(manifest?.schemaVersion).toBe(2); expect(manifest?.provides.map((provide) => provide.type)).toEqual(["fragments", "skills"]); + expect(manifest?.schemaVersion === 2 ? manifest.suggests?.brainstorm.reason : undefined).toBe("Divergent ideation before converging."); + expect(manifest?.schemaVersion === 2 && "items" in manifest.provides[1]! ? manifest.provides[1].items?.demo?.suggests : undefined).toEqual(["brainstorm"]); }); it("parses v2 meta-packages without a provides key", async () => { @@ -202,6 +214,7 @@ describe("OpenPack package manifests", () => { name: "bad-selectors/pkg", version: "1.0.0", requires: { core: { source: "registry:core", select: ["not-a-selector"] } }, + suggests: { brainstorm: { source: "registry:brainstorm", select: ["also-bad"] } }, provides: [ { type: "fragments", path: "fragments" }, { @@ -210,6 +223,7 @@ describe("OpenPack package manifests", () => { items: { demo: { requires: ["core:missing"], + suggests: [{ alias: "missing-suggestion", select: ["skills/demo"] }], compose: [{ include: "../escape.md" }], }, }, @@ -221,6 +235,7 @@ describe("OpenPack package manifests", () => { expect(result.ok).toBe(false); expect(result.findings.map((finding) => finding.message).join("\n")).toMatch(/invalid selector/); expect(result.findings.map((finding) => finding.message).join("\n")).toMatch(/include path|selector/); + expect(result.findings.map((finding) => finding.message).join("\n")).toMatch(/suggestion alias not declared/); }); it("migrates legacy manifests and is idempotent", async () => { diff --git a/test/resolve-graph.test.ts b/test/resolve-graph.test.ts index 6ae82f5..bb0af7a 100644 --- a/test/resolve-graph.test.ts +++ b/test/resolve-graph.test.ts @@ -74,6 +74,57 @@ describe("dependency graph resolver", () => { ]); }); + it("keeps suggested packages out of the graph unless suggestions are requested", async () => { + const workspace = await tempRoot(); + const root = join(workspace, "root"); + const dep = join(workspace, "dep"); + await writeText(join(root, "rules", "root.md"), "# Root\n"); + await writeText(join(dep, "rules", "dep.md"), "# Dep\n"); + await writeOpenPack(dep, { + name: "acme/dep", + provides: [{ type: "rules", path: "rules" }], + }); + await writeOpenPack(root, { + name: "acme/root", + suggests: { + brainstorm: { + source: "../dep", + select: ["rules/dep.md"], + reason: "Try a companion rule before converging.", + }, + }, + provides: [ + { + type: "rules", + path: "rules", + items: { + "root.md": { suggests: ["brainstorm"] }, + }, + }, + ], + }); + + const withoutSuggestions = await resolveDependencyGraph([ + { rootId: "main", source: root, select: ["rules/root.md"] }, + ], { workspaceRoot: workspace }); + expect(withoutSuggestions.nodes.map((node) => node.name)).toEqual(["acme/root"]); + + const withSuggestions = await resolveDependencyGraph([ + { rootId: "main", source: root, select: ["rules/root.md"], includeSuggestions: true }, + ], { workspaceRoot: workspace }); + const depNode = withSuggestions.nodes.find((node) => node.name === "acme/dep"); + expect(withSuggestions.nodes.map((node) => node.name).sort()).toEqual(["acme/dep", "acme/root"]); + expect(depNode?.selected).toEqual(["rules/dep.md"]); + expect(depNode?.selectionReasons?.["rules/dep.md"]).toEqual(["suggested by rules/root.md"]); + expect(withSuggestions.edges).toMatchObject([{ alias: "brainstorm", optional: true, selected: ["rules/dep.md"] }]); + + const withExplicitSuggestion = await resolveDependencyGraph([ + { rootId: "main", source: root, select: ["rules/root.md"], suggestionAliases: ["brainstorm"] }, + ], { workspaceRoot: workspace }); + expect(withExplicitSuggestion.nodes.map((node) => node.name).sort()).toEqual(["acme/dep", "acme/root"]); + expect(withExplicitSuggestion.edges).toMatchObject([{ alias: "brainstorm", optional: false }]); + }); + it("resolves root meta-packages that select local dependency artifacts", async () => { const workspace = await tempRoot(); const root = join(workspace, "root");