diff --git a/packages/agents/content/scripts/describe-change.sh b/packages/agents/content/scripts/describe-change.sh
new file mode 100755
index 00000000..cd14595a
--- /dev/null
+++ b/packages/agents/content/scripts/describe-change.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# Resolve commit, ticket, and PR prefixes from preferences.
+#
+# Reads prefix conventions from `.agents/preferences.yaml` (project) with
+# fallback to `~/.agents/preferences.yaml` (global), then to empty string.
+#
+# Usage:
+# describe-change.sh [--scope SCOPE] [--type TYPE]
+#
+# Output: JSON object with `commit_prefix`, `ticket_prefix`, and `pr_prefix`.
+# Non-empty values include a trailing `: `.
+
+set -euo pipefail
+
+scope=""
+type=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --scope)
+ scope="$2"
+ shift 2
+ ;;
+ --type)
+ type="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# Parse a specific prefix value from a YAML file.
+# Reads line-by-line, tracks the current top-level section, and matches
+# `prefix:` within the target section (commit, ticket, or pr).
+# Outputs "FOUND:{value}" when the key is present (value may be empty),
+# or nothing when the key is absent. This lets callers distinguish
+# "key absent" from "key present with empty value."
+parse_prefix() {
+ local file="$1"
+ local section="$2"
+ local current_section=""
+
+ if [[ ! -f "$file" ]]; then
+ return
+ fi
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ # Skip blank lines and comments
+ [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
+
+ # Detect top-level keys (no leading whitespace, ends with colon)
+ if [[ "$line" =~ ^[a-zA-Z_] ]]; then
+ current_section="${line%%:*}"
+ continue
+ fi
+
+ # Match prefix: within the target section
+ if [[ "$current_section" == "$section" && "$line" =~ ^[[:space:]]+prefix:[[:space:]]*(.*) ]]; then
+ local value="${BASH_REMATCH[1]}"
+ # Strip inline comments
+ value="${value%%#*}"
+ # Trim trailing whitespace
+ value="${value%"${value##*[![:space:]]}"}"
+ # Strip surrounding quotes
+ if [[ "$value" =~ ^\'(.*)\'$ ]]; then
+ value="${BASH_REMATCH[1]}"
+ elif [[ "$value" =~ ^\"(.*)\"$ ]]; then
+ value="${BASH_REMATCH[1]}"
+ fi
+ echo "FOUND:${value}"
+ return
+ fi
+ done < "$file"
+}
+
+# Resolve a prefix value by checking project, then global, then defaulting to empty.
+# parse_prefix returns "FOUND:{value}" when the key is present, or empty when absent.
+# This lets an explicit empty value at the project level override a global non-empty value.
+resolve_prefix() {
+ local section="$1"
+ local result
+
+ # Project preferences
+ result="$(parse_prefix ".agents/preferences.yaml" "$section")"
+ if [[ "$result" == FOUND:* ]]; then
+ echo "${result#FOUND:}"
+ return
+ fi
+
+ # Global preferences
+ result="$(parse_prefix "$HOME/.agents/preferences.yaml" "$section")"
+ if [[ "$result" == FOUND:* ]]; then
+ echo "${result#FOUND:}"
+ return
+ fi
+
+ echo ""
+}
+
+# Format a prefix string from a convention template, scope, and type.
+# Convention placeholders: {scope}, {type}.
+# When convention is empty, always emit empty.
+# When only --type is provided, emit `{type}: ` regardless of convention.
+# When only --scope or neither is provided, emit empty.
+format_prefix() {
+ local convention="$1"
+
+ # Empty convention means no prefix
+ if [[ -z "$convention" ]]; then
+ echo ""
+ return
+ fi
+
+ # Neither scope nor type
+ if [[ -z "$scope" && -z "$type" ]]; then
+ echo ""
+ return
+ fi
+
+ # Only scope, no type
+ if [[ -n "$scope" && -z "$type" ]]; then
+ echo ""
+ return
+ fi
+
+ # Only type, no scope
+ if [[ -z "$scope" && -n "$type" ]]; then
+ echo "${type}: "
+ return
+ fi
+
+ # Both scope and type: substitute into convention
+ local result="$convention"
+ result="${result//\{scope\}/$scope}"
+ result="${result//\{type\}/$type}"
+ echo "${result}: "
+}
+
+# Escape backslashes and double quotes for safe JSON interpolation.
+json_escape() {
+ local s="$1"
+ s="${s//\\/\\\\}"
+ s="${s//\"/\\\"}"
+ echo "$s"
+}
+
+commit_convention="$(resolve_prefix "commit")"
+ticket_convention="$(resolve_prefix "ticket")"
+pr_convention="$(resolve_prefix "pr")"
+
+commit_prefix="$(json_escape "$(format_prefix "$commit_convention")")"
+ticket_prefix="$(json_escape "$(format_prefix "$ticket_convention")")"
+pr_prefix="$(json_escape "$(format_prefix "$pr_convention")")"
+
+printf '{"commit_prefix":"%s","ticket_prefix":"%s","pr_prefix":"%s"}\n' \
+ "$commit_prefix" "$ticket_prefix" "$pr_prefix"
diff --git a/packages/agents/content/skills/_data/commit-format.md b/packages/agents/content/skills/_data/commit-format.md
index f99cefd2..12ea81c9 100644
--- a/packages/agents/content/skills/_data/commit-format.md
+++ b/packages/agents/content/skills/_data/commit-format.md
@@ -1,50 +1,75 @@
# Git commit format
-## Commit title format
+## Commit title prefix
-The standard commit title is 72 characters max (hard limit):
+Commit titles may include a prefix that identifies scope (workspace, package, module) and work type. The prefix format is configurable per repository and per user.
-```txt
-{workspace}|{WORK_TYPE}: {commit title}
+### Resolving the prefix
+
+Run the `describe-change.sh` script to resolve the correct prefix:
+
+```bash
+{skills_root}/../scripts/describe-change.sh --scope {scope} --type {type}
```
-If the project does not have monorepo workspaces, omit the `{workspace}`:
+The script reads `commit.prefix`, `ticket.prefix`, and `pr.prefix` from `.agents/preferences.yaml` (project) then `~/.agents/preferences.yaml` (global), falling back to empty string. It outputs JSON:
+```json
+{ "commit_prefix": "agents|feat: ", "ticket_prefix": "agents|feat: ", "pr_prefix": "agents|feat: " }
```
-{WORK_TYPE}: {description}
+
+Use the `commit_prefix` field for commit titles. Non-empty values already include the trailing `: `.
+
+If the script is not found, produce no prefix.
+
+### Supported conventions
+
+Configure the prefix convention in `.agents/preferences.yaml` or `~/.agents/preferences.yaml`:
+
+```yaml
+commit:
+ prefix: '{scope}|{type}'
```
-Add `!` after the work type to indicate breaking changes: `ts|feat!: Remove deprecated API`
+| Convention | Example with scope + type | Example with type only |
+| ----------------- | ------------------------------------ | ---------------------------- |
+| `{scope}\|{type}` | `agents\|feat: Add script installer` | `feat: Add script installer` |
+| `{type}({scope})` | `feat(agents): Add script installer` | `feat: Add script installer` |
+| `{type}` | `feat: Add script installer` | `feat: Add script installer` |
+| `''` (empty) | `Add script installer` | `Add script installer` |
-## Ticket ID
+When only `--type` is provided (no `--scope`), the prefix is always `{type}: ` regardless of convention. When only `--scope` or neither is provided, the prefix is empty.
-Do not include the ticket ID in the commit title. The branch name already carries it.
+### Scope
-Include the ticket ID at the end of the commit body only if the branch covers more than one ticket (rare).
+The scope identifies the part of the codebase affected by the commit:
-## Line length
+- In a monorepo, the scope is typically the workspace name or abbreviation.
+- Use `root` if the commit touches only files in the monorepo root.
+- Use `*` if the commit spans multiple workspaces, or root and one or more workspaces.
+- If a root change is tightly associated with only one workspace, don't count it as a root change.
-- **Title**: 72 characters max (hard limit).
-- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width.
+Common example: if a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root.
-## Examples
+## Title constraints
-### Monorepo workspace
+- **72 characters max** (hard limit).
+- **Describes the code change, not what prompted it.** Ask: "what does the diff do?" Bad: "Address review findings". Good: "Add error logging to `handleStateUpdate`".
+- **No ephemeral references.** If it won't make sense to a reader who has only `git log`, leave it out.
+- **Only document what's in the diff.** External actions (e.g., updating a ticket) don't belong.
-In a monorepo the workspace is usually the name (or abbreviated name) of the workspace changed by the commit:
+Add `!` after the work type to indicate breaking changes: `agents|feat!: Remove deprecated API`
-```
-web|tests: Fix ProgressNotes tests broken by upgrades
-*|internal: Add user route and user profile component
-admin|deps!: Upgrade React to v18
-```
+## Ticket ID
-### Non-monorepo
+Do not include the ticket ID in the commit title. The branch name already carries it.
-```
-feat: Add user profile component
-deps: Upgrade React to v18
-```
+Include the ticket ID at the end of the commit body only if the branch covers more than one ticket (rare).
+
+## Line length
+
+- **Title**: 72 characters max (hard limit).
+- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width.
## Body formatting
@@ -57,20 +82,3 @@ deps: Upgrade React to v18
## Branch naming
See `branch-format.md` for branch naming conventions. Branch format: `{ticket}/{description}`.
-
-## Legacy format
-
-This was the previously used format. Some projects still use it, but don't propagate it. The `{TICKET}` prefix in these templates is part of the old format and should not be used in new commits.
-
-```txt
-{workspace} {TICKET}: [{WORK_TYPE}] {description}
-
-# No ticket
-{workspace} [{WORK_TYPE}] {description}
-
-# Not a monorepo
-{TICKET}: [{WORK_TYPE}] {description}
-
-# No ticket, not a monorepo
-[{WORK_TYPE}] {description}
-```
diff --git a/packages/agents/content/skills/commit/SKILL.md b/packages/agents/content/skills/commit/SKILL.md
index aafe2aa1..2ae24779 100644
--- a/packages/agents/content/skills/commit/SKILL.md
+++ b/packages/agents/content/skills/commit/SKILL.md
@@ -8,22 +8,12 @@ user-invocable: true
## Commit message format
-Commit titles follow this format:
-
-```txt
-{workspace}|{WORK_TYPE}: {commit title}
-```
-
-See `../_data/commit-format.md` for full specification.
+See `../_data/commit-format.md` for the full specification, including how to resolve the commit title prefix using `describe-change.sh`.
## Commit metadata
- `WORK_TYPE` describes the category of work (see `../_data/work-types.md`)
-Example: `web|tests: Fix PromoPage tests`
-
-- WORK_TYPE: `tests`
-
## Ticket ID
Do not include the ticket ID in the commit title. The branch name carries it. Include it at the end of the commit body only if the branch covers more than one ticket (rare).
@@ -45,11 +35,11 @@ Do not include the ticket ID in the commit title. The branch name carries it. In
See `../_data/commit-format.md` for body formatting rules (punctuation, backtick formatting, paragraph structure, and what to omit).
-## Changes touching multiple workspaces
+## Changes touching multiple scopes
- Use `root` if commit touches only files in monorepo root
-- Use `*` if commit comprises changes to multiple workspaces, or root and one or more workspaces
-- If a root change is tightly associated with only one workspace, don't count it as a root change
+- Use `*` if commit comprises changes to multiple scopes, or root and one or more scopes
+- If a root change is tightly associated with only one scope, don't count it as a root change
Common example: If a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root.
diff --git a/packages/agents/content/skills/condense-branch/SKILL.md b/packages/agents/content/skills/condense-branch/SKILL.md
index a0244d86..1838bd4a 100644
--- a/packages/agents/content/skills/condense-branch/SKILL.md
+++ b/packages/agents/content/skills/condense-branch/SKILL.md
@@ -44,13 +44,13 @@ Use `summarize-change` to compose a good commit message. Save the description pe
## Commit format
-Follow [commit-format.md](../_data/commit-format.md):
+Follow [commit-format.md](../_data/commit-format.md). Use `describe-change.sh` to resolve the commit title prefix:
+```bash
+{skills_root}/../scripts/describe-change.sh --scope {scope} --type {type}
```
-{workspace}|{WORK_TYPE}: {title}
-{body}
-```
+Use the `commit_prefix` field from the JSON output as the title prefix.
## Safety
diff --git a/packages/agents/content/skills/create-ticket/SKILL.md b/packages/agents/content/skills/create-ticket/SKILL.md
index 950afc31..dd298a8b 100644
--- a/packages/agents/content/skills/create-ticket/SKILL.md
+++ b/packages/agents/content/skills/create-ticket/SKILL.md
@@ -63,10 +63,17 @@ Determine where to create the remote ticket:
#### GitHub path
+Resolve the ticket prefix using `describe-change.sh`:
+
+```bash
+json=$({skills_root}/../scripts/describe-change.sh --scope {scope} --type {type})
+change_prefix=$(echo "$json" | grep -o '"ticket_prefix":"[^"]*"' | cut -d'"' -f4)
+```
+
Create the issue **without** the ticket ID prefix in the title:
```bash
-url=$(gh issue create --title "{scope}|{type}: {title}" --body "{ticket body}")
+url=$(gh issue create --title "${change_prefix}{title}" --body "{ticket body}")
```
Extract the issue number from the returned URL:
diff --git a/packages/agents/content/skills/prepare-pr/SKILL.md b/packages/agents/content/skills/prepare-pr/SKILL.md
index 3701f492..650fe24e 100644
--- a/packages/agents/content/skills/prepare-pr/SKILL.md
+++ b/packages/agents/content/skills/prepare-pr/SKILL.md
@@ -23,7 +23,16 @@ git rev-parse --short HEAD
4. **If no match found**: Use `summarize-change` first, then continue
-5. **Create PR description**:
+5. **Resolve PR title prefix** using `describe-change.sh`:
+
+```bash
+json=$({skills_root}/../scripts/describe-change.sh --scope {scope} --type {type})
+pr_prefix=$(echo "$json" | grep -o '"pr_prefix":"[^"]*"' | cut -d'"' -f4)
+```
+
+Use `${pr_prefix}{title}` as the PR title. See [commit-format.md](../_data/commit-format.md) for prefix conventions.
+
+6. **Create PR description**:
- Copy change summary content
- Save per the [Saving](#saving) section
diff --git a/packages/agents/content/skills/summarize-change/SKILL.md b/packages/agents/content/skills/summarize-change/SKILL.md
index 03925a1c..8869ebe7 100644
--- a/packages/agents/content/skills/summarize-change/SKILL.md
+++ b/packages/agents/content/skills/summarize-change/SKILL.md
@@ -12,7 +12,6 @@ Analyze the current branch's changes since diverging from the default branch.
1. **Gather context**:
- Use `get-session-context` to obtain `default_branch` and `ticket_id`; consult [work-types.md](../_data/work-types.md).
- - Determine workspace from commit subjects: parse `{workspace}|{type}:` prefixes, collect unique values. Single → use it. Multiple → use `*`. None → omit.
2. **Analyze changes**:
@@ -22,9 +21,7 @@ git diff $DEFAULT_BRANCH...HEAD
Check commit messages for additional context.
-3. **Compose title**: `{ticket ID} {workspace} | {work type}: {title}`
- - Omit empty segments
- - Pipe (`|`) must have a space on both sides
+3. **Compose title**: `{ticket ID} {title}`
- Ticket ID appears in the change summary title (for identification) but must never appear in commit titles (per `commit` skill)
4. **Write description** per the output format below
@@ -36,7 +33,7 @@ If expected information is missing, stop and ask the developer.
## Output format
```markdown
-# {TICKET} {workspace} | {work type}: {title}
+# {TICKET} {title}
Commit: {short hash of HEAD}
Timestamp: {YYYY-MM-DD HH:MMZ format}
diff --git a/packages/agents/content/subagents/orchestrated-coder.md b/packages/agents/content/subagents/orchestrated-coder.md
index 3188e2d3..fd440f4f 100644
--- a/packages/agents/content/subagents/orchestrated-coder.md
+++ b/packages/agents/content/subagents/orchestrated-coder.md
@@ -119,12 +119,13 @@ If the project does not have a particular quality gate configured, note "N/A" fo
## Commit formatting
-Every commit message MUST satisfy all four rules. Violations are treated as quality gate failures.
+Every commit message MUST satisfy all five rules. Violations are treated as quality gate failures.
-1. **Title describes the code change, not the process.** Ask "what does the diff do?" — never "why did I open the editor?" Forbidden: "Address review findings," "Apply feedback," "Fix issues from review," "Incorporate suggestions." Required: describe the actual change — "Fix null check in layout resolver," "Remove unused layout fields."
-2. **Title is 72 characters max.** Count characters before committing. If it's too long, shorten it.
-3. **No hard line breaks in the body.** Write naturally as continuous text. Do not insert newlines to wrap at a fixed column width.
-4. **Use backtick formatting for code identifiers.** Variable names, function names, class names, file paths, and other code references must be wrapped in backticks — e.g., `handleStateUpdate`, `AgentActor`, `stationIndex`.
+1. **Resolve the title prefix** using `describe-change.sh` (see `commit-format.md` in the commit skill's `_data/` directory). If the script is not found, produce no prefix.
+2. **Title describes the code change, not the process.** Ask "what does the diff do?" — never "why did I open the editor?" Forbidden: "Address review findings," "Apply feedback," "Fix issues from review," "Incorporate suggestions." Required: describe the actual change — "Fix null check in layout resolver," "Remove unused layout fields."
+3. **Title is 72 characters max.** Count characters before committing. If it's too long, shorten it.
+4. **No hard line breaks in the body.** Write naturally as continuous text. Do not insert newlines to wrap at a fixed column width.
+5. **Use backtick formatting for code identifiers.** Variable names, function names, class names, file paths, and other code references must be wrapped in backticks — e.g., `handleStateUpdate`, `AgentActor`, `stationIndex`.
## Constraints
diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts
index 05ea9744..46f9f4b5 100644
--- a/packages/agents/src/commands/install.ts
+++ b/packages/agents/src/commands/install.ts
@@ -1,4 +1,4 @@
-import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
+import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { resolveContentDir } from '../lib/content-resolver.js';
@@ -7,7 +7,14 @@ import { checkSymlinkSafety, copyItem, linkItem, unlinkIfSymlink } from '../lib/
import { computeContentHash, detectDrift, getManifestPath, readManifest, writeManifest } from '../lib/manifest.js';
import { rewritePathsInDirectory } from '../lib/path-rewriter.js';
import { PLATFORMS, resolvePlatformIds, resolvePlatformPaths } from '../lib/platform.js';
-import type { AgentsManifest, InstallOptions, ManifestEntry, PlatformId, PlatformManifest } from '../lib/types.js';
+import type {
+ AgentsManifest,
+ InstallOptions,
+ ManifestEntry,
+ PlatformConfig,
+ PlatformId,
+ PlatformManifest,
+} from '../lib/types.js';
/**
* Executes the install command, installing skills and subagents for the specified platforms.
@@ -32,6 +39,7 @@ export async function installCommand(options: InstallOptions, baseDir?: string):
// Safety check: ensure target directories are not symlinks
checkSymlinkSafety(paths.skillsDir);
checkSymlinkSafety(paths.subagentsDir);
+ checkSymlinkSafety(paths.scriptsDir);
// Build lookup of previously installed entries for drift detection
const existingEntries = manifest.platforms[platformId]?.entries ?? [];
@@ -57,6 +65,17 @@ export async function installCommand(options: InstallOptions, baseDir?: string):
const subagentEntries = await installSubagents(contentDir, paths, platformId, existingByPath, options);
entries.push(...subagentEntries);
+ // Install scripts
+ const scriptEntries = await installScripts(
+ contentDir,
+ paths.scriptsDir,
+ paths.platformHome,
+ platformConfig,
+ existingByPath,
+ options,
+ );
+ entries.push(...scriptEntries);
+
// Generate prompts.yml for Rovo Dev (skill discovery file)
if (platformId === 'rovodev') {
const promptsEntry = await generatePromptsYml(paths, existingByPath, options);
@@ -69,6 +88,7 @@ export async function installCommand(options: InstallOptions, baseDir?: string):
console.info(` [dry-run] Would install ${entries.length} items:`);
console.info(` ${skillEntries.length} skill items`);
console.info(` ${subagentEntries.length} subagent items`);
+ console.info(` ${scriptEntries.length} script items`);
continue;
}
@@ -414,6 +434,80 @@ async function generatePromptsYml(
};
}
+/**
+ * Installs script files from content/scripts/ into the target scripts directory.
+ * Scripts are flat files (no frontmatter, no platform-specific variants).
+ * Copied scripts receive the executable bit (0o755); symlinked scripts inherit
+ * the source's permissions.
+ */
+async function installScripts(
+ contentDir: string,
+ scriptsDestDir: string,
+ platformHome: string,
+ platformConfig: PlatformConfig,
+ existingByPath: ReadonlyMap,
+ options: InstallOptions,
+): Promise> {
+ const scriptsSrcDir = path.join(contentDir, 'scripts');
+ let dirEntries: ReadonlyArray;
+ try {
+ dirEntries = await readdir(scriptsSrcDir);
+ } catch (error: unknown) {
+ if (!isEnoent(error)) {
+ throw error;
+ }
+ console.warn(` Warning: no scripts directory found at ${scriptsSrcDir}, skipping script installation`);
+ return [];
+ }
+
+ const entries: Array = [];
+
+ for (const entry of dirEntries) {
+ if (entry.startsWith('.')) {
+ continue;
+ }
+
+ const srcPath = path.join(scriptsSrcDir, entry);
+ const destPath = path.join(scriptsDestDir, entry);
+ const relativePath = `${platformConfig.scriptsDir}/${entry}`;
+
+ if (options.dryRun) {
+ const action = options.link ? 'link' : 'copy';
+ console.info(` [${action}] ${relativePath}`);
+ entries.push({ relativePath, contentHash: 'dry-run', linked: options.link });
+ continue;
+ }
+
+ // Check for user modifications before overwriting
+ const existingEntry = existingByPath.get(relativePath);
+ if (existingEntry && !options.force) {
+ const drift = await detectDrift(existingEntry, platformHome);
+ if (drift === 'modified') {
+ console.warn(` Skipping modified item: ${relativePath}`);
+ entries.push(existingEntry);
+ continue;
+ }
+ }
+
+ await (options.link ? linkItem(srcPath, destPath) : copyItem(srcPath, destPath));
+
+ // Ensure copied scripts are executable
+ if (!options.link) {
+ await chmod(destPath, 0o755);
+ }
+
+ // Compute hash from source for symlinked scripts (dest symlink may not resolve in all environments)
+ const hashPath = options.link ? srcPath : destPath;
+ entries.push({
+ relativePath,
+ contentHash: await computeContentHash(hashPath),
+ linked: options.link,
+ });
+ }
+
+ return entries;
+}
+
/**
* Type guard that checks whether an error is a Node.js ENOENT error.
*/
diff --git a/packages/agents/src/lib/__tests__/platform.test.ts b/packages/agents/src/lib/__tests__/platform.test.ts
index f7843263..d24b1291 100644
--- a/packages/agents/src/lib/__tests__/platform.test.ts
+++ b/packages/agents/src/lib/__tests__/platform.test.ts
@@ -54,6 +54,7 @@ describe('platform', () => {
expect(result.platformHome).toBe(path.join(tempDir, PLATFORMS.claude.homeDir));
expect(result.skillsDir).toBe(path.join(tempDir, PLATFORMS.claude.homeDir, PLATFORMS.claude.skillsDir));
expect(result.subagentsDir).toBe(path.join(tempDir, PLATFORMS.claude.homeDir, PLATFORMS.claude.subagentsDir));
+ expect(result.scriptsDir).toBe(path.join(tempDir, PLATFORMS.claude.homeDir, PLATFORMS.claude.scriptsDir));
});
it('should resolve correct paths for rovodev platform', () => {
@@ -62,6 +63,7 @@ describe('platform', () => {
expect(result.platformHome).toBe(path.join(tempDir, PLATFORMS.rovodev.homeDir));
expect(result.skillsDir).toBe(path.join(tempDir, PLATFORMS.rovodev.homeDir, PLATFORMS.rovodev.skillsDir));
expect(result.subagentsDir).toBe(path.join(tempDir, PLATFORMS.rovodev.homeDir, PLATFORMS.rovodev.subagentsDir));
+ expect(result.scriptsDir).toBe(path.join(tempDir, PLATFORMS.rovodev.homeDir, PLATFORMS.rovodev.scriptsDir));
});
it('should produce absolute paths containing the platform home directory', () => {
@@ -69,6 +71,7 @@ describe('platform', () => {
expect(result.skillsDir.startsWith(result.platformHome)).toBe(true);
expect(result.subagentsDir.startsWith(result.platformHome)).toBe(true);
+ expect(result.scriptsDir.startsWith(result.platformHome)).toBe(true);
});
});
});
diff --git a/packages/agents/src/lib/platform.ts b/packages/agents/src/lib/platform.ts
index 41857531..b299e142 100644
--- a/packages/agents/src/lib/platform.ts
+++ b/packages/agents/src/lib/platform.ts
@@ -11,6 +11,7 @@ export const PLATFORMS: Record = {
homeDir: '.claude',
skillsDir: 'skills',
subagentsDir: 'agents',
+ scriptsDir: 'scripts',
frontmatterFile: 'claude.yml',
},
rovodev: {
@@ -18,6 +19,7 @@ export const PLATFORMS: Record = {
homeDir: '.rovodev',
skillsDir: 'skills',
subagentsDir: 'subagents',
+ scriptsDir: 'scripts',
frontmatterFile: 'rovodev.yml',
},
};
@@ -48,6 +50,7 @@ export function resolvePlatformPaths(
platformHome: string;
skillsDir: string;
subagentsDir: string;
+ scriptsDir: string;
} {
const home = baseDir ?? homedir();
const config = PLATFORMS[platformId];
@@ -56,6 +59,7 @@ export function resolvePlatformPaths(
platformHome,
skillsDir: path.join(platformHome, config.skillsDir),
subagentsDir: path.join(platformHome, config.subagentsDir),
+ scriptsDir: path.join(platformHome, config.scriptsDir),
};
}
diff --git a/packages/agents/src/lib/types.ts b/packages/agents/src/lib/types.ts
index 50822139..b65752aa 100644
--- a/packages/agents/src/lib/types.ts
+++ b/packages/agents/src/lib/types.ts
@@ -10,6 +10,8 @@ export interface PlatformConfig {
readonly skillsDir: string;
/** Relative path from the platform home to the subagents directory. */
readonly subagentsDir: string;
+ /** Relative path from the platform home to the scripts directory. */
+ readonly scriptsDir: string;
/** Filename of the frontmatter overlay YAML for this platform. */
readonly frontmatterFile: string;
}