Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>` for one named suggestion. The choice is saved when used with `add` or
`install <source>`.

```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:
Expand All @@ -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"] }
}
}
]
}
```
Expand All @@ -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 <glob>` 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 <alias>` 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
Expand Down
15 changes: 15 additions & 0 deletions docs/spec/openpack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand All @@ -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 }
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions skills/agentwheel/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>` 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:
Expand Down
47 changes: 47 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ program
.option("--name <name>", "package alias")
.option("--select <type/name>", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "include one suggested companion alias on future installs (repeatable or comma-separated)", collectSuggestionOption, [] as string[])
.option("--override <source-or-package::type/name>", "allow this package to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[])
.action(async (source, options) => {
const normalizedOptions = normalizeRuntimeScopeOptions(options);
Expand Down Expand Up @@ -172,6 +174,8 @@ program
.option("--mode <mode>", "pinned or tracking")
.option("--select <type/name>", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[])
.option("--override <source-or-package::type/name>", "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)
Expand Down Expand Up @@ -206,6 +210,8 @@ program
.option("--mode <mode>", "pinned or tracking")
.option("--select <type/name>", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[])
.option("--override <source-or-package::type/name>", "when adding a source, allow it to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[])
.option("--profile <name>", "workspace runtime profile")
.option("--dry-run", "show plan without writing", false)
Expand Down Expand Up @@ -242,6 +248,8 @@ program
.option("--mode <mode>", "pinned or tracking")
.option("--select <type/name>", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "include one suggested companion alias (repeatable or comma-separated)", collectSuggestionOption, [] as string[])
.option("--override <source-or-package::type/name>", "when adding a source, allow it to replace a colliding artifact (repeatable)", collectOverrideOption, [] as string[])
.option("--profile <name>", "workspace runtime profile")
.option("--dry-run", "show plan without writing", false)
Expand Down Expand Up @@ -280,6 +288,8 @@ program
.option("--allow-adapter-code", "allow loading local adapter code from configured packages", false)
.option("--select <type/name>", "temporarily select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "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)
Expand Down Expand Up @@ -313,6 +323,8 @@ program
.option("--mode <mode>", "pinned or tracking")
.option("--select <type/name>", "select an artifact by type/name (repeatable or comma-separated)", collectSelectOption, [] as string[])
.option("--skill <name>", "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 <alias>", "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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -944,6 +963,9 @@ interface GraphCliOptions {
overrides?: string[];
noDeps?: boolean;
deps?: boolean;
withSuggestions?: boolean;
suggestion?: string[];
suggestions?: string[];
onlySource?: boolean;
frozenLock?: boolean;
offline?: boolean;
Expand Down Expand Up @@ -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,
};
}),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/lifecycle/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface ProfileSyncOptions {
forceConflict?: boolean;
replaceConflict?: boolean;
noDeps?: boolean;
includeSuggestions?: boolean;
suggestionAliases?: string[];
lockedResolution?: boolean;
frozenLock?: boolean;
offline?: boolean;
Expand Down Expand Up @@ -84,6 +86,8 @@ export async function syncProfile(options: ProfileSyncOptions): Promise<ProfileS
select: selected ?? normalizeArtifactSelectors(pkg.select, pkg.skills),
aliases: pkg.aliases,
overrides: pkg.overrides,
includeSuggestions: options.includeSuggestions === true || pkg.withSuggestions === true,
suggestionAliases: combinedSuggestionAliases(pkg.suggestions, options.suggestionAliases),
})),
targetRoot: target.targetRoot,
workspaceRoot: options.workspaceRoot,
Expand All @@ -103,6 +107,8 @@ export async function syncProfile(options: ProfileSyncOptions): Promise<ProfileS
},
installationType,
noDeps: options.noDeps,
includeSuggestions: options.includeSuggestions,
suggestionAliases: options.suggestionAliases,
lockedResolution: options.lockedResolution,
frozenLock: options.frozenLock,
offline: options.offline,
Expand Down Expand Up @@ -153,5 +159,12 @@ async function packageFromSource(source: string, options: ProfileSyncOptions): P
mode: options.mode ?? "pinned",
select: options.select,
skills: options.skills,
withSuggestions: options.includeSuggestions === true ? true : undefined,
suggestions: options.suggestionAliases,
};
}

function combinedSuggestionAliases(packageAliases: string[] | undefined, optionAliases: string[] | undefined): string[] | undefined {
const aliases = [...(packageAliases ?? []), ...(optionAliases ?? [])].map((item) => item.trim()).filter(Boolean);
return aliases.length > 0 ? [...new Set(aliases)].sort((a, b) => a.localeCompare(b)) : undefined;
}
4 changes: 4 additions & 0 deletions src/lifecycle/source-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface GraphSourcePlanOptions {
targetKey?: string;
targetFingerprintParts?: unknown;
noDeps?: boolean;
includeSuggestions?: boolean;
suggestionAliases?: string[];
lockedResolution?: boolean;
frozenLock?: boolean;
offline?: boolean;
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/model/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ export const packageItemRequireSchema = z.union([
]);
export type PackageItemRequire = z.infer<typeof packageItemRequireSchema>;

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<typeof packageItemSuggestSchema>;

export const composedFromEntrySchema = z.object({
selector: z.string().min(1),
hash: z.string().min(16),
Expand All @@ -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(),
Expand Down
Loading