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
71 changes: 71 additions & 0 deletions packages/cli/src/persona-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,56 @@ test('installs sidecar markdown files into __assets/<id>/ and rewrites JSON path
});
});

test('installs local skill markdown files into __assets/<id>/ and rewrites JSON sources', () => {
withTemp((root) => {
const project = join(root, 'project');
const pack = join(root, 'pack');
const persona = fullPersona('router-bot') as Record<string, unknown>;
persona.skills = [
{
id: 'local-routing-map',
source: './skills/routing-map.md',
description: 'Local routing map shipped by the persona package.'
},
{
id: '@agent-relay/workspace-layout',
source: '@agent-relay/workspace-layout',
description: 'Remote prpm skill remains remote.'
}
];
writeJson(join(pack, 'personas', 'router-bot.json'), persona);
mkdirSync(join(pack, 'skills'), { recursive: true });
writeFileSync(join(pack, 'skills', 'routing-map.md'), '# Routing map\n');

const result = installPersonas({ source: pack, cwd: project });
assert.equal(result.installed.length, 1);

const installedJson = readJson(
join(project, '.agentworkforce', 'workforce', 'personas', 'router-bot.json')
) as { skills: Array<{ source: string }> };
assert.equal(
installedJson.skills[0].source,
'.agentworkforce/workforce/personas/__assets/router-bot/skills/routing-map.md'
);
assert.equal(installedJson.skills[1].source, '@agent-relay/workspace-layout');

const installedSkill = readFileSync(
join(
project,
'.agentworkforce',
'workforce',
'personas',
'__assets',
'router-bot',
'skills',
'routing-map.md'
),
'utf8'
);
assert.equal(installedSkill, '# Routing map\n');
});
});

test('rejects sidecar paths that escape the persona dir', () => {
withTemp((root) => {
const project = join(root, 'project');
Expand All @@ -318,6 +368,27 @@ test('rejects sidecar paths that escape the persona dir', () => {
});
});

test('hard-fails on missing referenced local skill markdown', () => {
withTemp((root) => {
const project = join(root, 'project');
const pack = join(root, 'pack');
const persona = fullPersona('router-bot') as Record<string, unknown>;
persona.skills = [
{
id: 'missing-skill',
source: './skills/missing.md',
description: 'Missing local skill.'
}
];
writeJson(join(pack, 'personas', 'router-bot.json'), persona);

assert.throws(
() => installPersonas({ source: pack, cwd: project }),
/referenced skill source not found/
);
});
});

test('hard-fails on missing referenced .md sidecar', () => {
withTemp((root) => {
const project = join(root, 'project');
Expand Down
127 changes: 106 additions & 21 deletions packages/cli/src/persona-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,23 @@ interface PackageJsonShape {
* the persona — a stable POSIX-style relative path used as the on-disk
* filename under `__assets/<personaId>/`.
*/
interface PersonaAsset {
/** Where the field appears in the JSON (for path-rewriting). */
field: 'claudeMd' | 'agentsMd';
sourcePath: string;
/** Stable relative target inside `<targetDir>/__assets/<personaId>/`. */
assetKey: string;
}
type PersonaAsset =
| {
kind: 'sidecar';
/** Where the field appears in the JSON (for path-rewriting). */
field: 'claudeMd' | 'agentsMd';
sourcePath: string;
/** Stable relative target inside `<targetDir>/__assets/<personaId>/`. */
assetKey: string;
}
| {
kind: 'skill';
/** Index into the persona `skills[]` array. */
skillIndex: number;
sourcePath: string;
/** Stable relative target inside `<targetDir>/__assets/<personaId>/`. */
assetKey: string;
};

interface PersonaFile {
id: string;
Expand Down Expand Up @@ -243,7 +253,47 @@ function assertPackagedSidecarPath(
return normalize(relPath);
}

function collectPersonaAssets(personaJsonPath: string, json: Record<string, unknown>): PersonaAsset[] {
function assertPackagedSkillPath(
relPath: unknown,
context: string,
jsonPath: string
): string | undefined {
if (typeof relPath !== 'string' || !relPath.trim()) return undefined;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(relPath)) return undefined;
if (!relPath.toLowerCase().endsWith('.md')) return undefined;
if (isAbsolute(relPath)) {
throw new Error(
`install: ${jsonPath}: ${context} local skill source must be relative; got "${relPath}"`
);
}
const segments = relPath.split(/[\\/]+/);
if (segments.some((s) => s === '..')) {
throw new Error(
`install: ${jsonPath}: ${context} local skill source must not contain ".." segments`
);
}
return normalize(relPath);
}

function uniqueAssetKey(baseKey: string, assetKeys: Set<string>): string {
let assetKey = baseKey;
let suffix = 1;
while (assetKeys.has(assetKey)) {
const dot = baseKey.lastIndexOf('.');
assetKey =
dot >= 0
? `${baseKey.slice(0, dot)}-${++suffix}${baseKey.slice(dot)}`
: `${baseKey}-${++suffix}`;
}
assetKeys.add(assetKey);
return assetKey;
}

function collectPersonaAssets(
personaJsonPath: string,
json: Record<string, unknown>,
packageRoot: string
): PersonaAsset[] {
const assets: PersonaAsset[] = [];
const assetKeys = new Set<string>();
const sourceDir = dirname(personaJsonPath);
Expand All @@ -262,15 +312,25 @@ function collectPersonaAssets(personaJsonPath: string, json: Record<string, unkn
`install: ${personaJsonPath}: referenced ${field} sidecar not found at ${sourcePath}`
);
}
const baseKey = basename(relPath);
let assetKey = baseKey;
let suffix = 1;
while (assetKeys.has(assetKey)) {
const dot = baseKey.lastIndexOf('.');
assetKey = `${baseKey.slice(0, dot)}-${++suffix}${baseKey.slice(dot)}`;
const assetKey = uniqueAssetKey(basename(relPath), assetKeys);
assets.push({ kind: 'sidecar', field, sourcePath, assetKey });
};

const addSkillAsset = (skillIndex: number, relPath: string): void => {
const sourcePath = resolvePath(packageRoot, relPath);
const fromRoot = relative(packageRoot, sourcePath);
if (fromRoot.startsWith('..') || isAbsolute(fromRoot)) {
throw new Error(
`install: ${personaJsonPath}: skill source "${relPath}" resolves outside the package`
);
}
if (!existsSync(sourcePath)) {
throw new Error(
`install: ${personaJsonPath}: referenced skill source not found at ${sourcePath}`
);
}
assetKeys.add(assetKey);
assets.push({ field, sourcePath, assetKey });
const assetKey = uniqueAssetKey(`skills/${basename(relPath)}`, assetKeys);
assets.push({ kind: 'skill', skillIndex, sourcePath, assetKey });
};

for (const field of sidecarFields) {
Expand All @@ -279,6 +339,17 @@ function collectPersonaAssets(personaJsonPath: string, json: Record<string, unkn
addAsset(field, rel);
}
}
if (Array.isArray(json.skills)) {
for (const [idx, rawSkill] of json.skills.entries()) {
if (!isPlainObject(rawSkill)) continue;
const rel = assertPackagedSkillPath(
rawSkill.source,
`skills[${idx}].source`,
personaJsonPath
);
if (rel) addSkillAsset(idx, rel);
}
}
return assets;
}

Expand All @@ -289,9 +360,19 @@ function rewriteJsonAssetPaths(
): Record<string, unknown> {
if (assets.length === 0) return json;
const cloned = JSON.parse(JSON.stringify(json)) as Record<string, unknown>;
const newPath = (assetKey: string): string => `__assets/${personaId}/${assetKey}`;
const localPersonaAssetPath = (assetKey: string): string =>
`.agentworkforce/workforce/personas/__assets/${personaId}/${assetKey}`;
const sidecarPath = (assetKey: string): string => `__assets/${personaId}/${assetKey}`;
for (const asset of assets) {
cloned[asset.field] = newPath(asset.assetKey);
if (asset.kind === 'sidecar') {
cloned[asset.field] = sidecarPath(asset.assetKey);
continue;
}
const skills = cloned.skills;
if (!Array.isArray(skills)) continue;
const skill = skills[asset.skillIndex];
if (!isPlainObject(skill)) continue;
skill.source = localPersonaAssetPath(asset.assetKey);
}
return cloned;
}
Expand All @@ -307,7 +388,11 @@ function dedupe(values: readonly string[]): string[] {
return out;
}

function collectPersonas(personaDir: string, targetDir: string): PersonaFile[] {
function collectPersonas(
personaDir: string,
targetDir: string,
packageRoot: string
): PersonaFile[] {
const jsonFiles = collectJsonFiles(personaDir);
if (jsonFiles.length === 0) {
throw new Error(`install: no persona JSON files found in ${personaDir}`);
Expand Down Expand Up @@ -335,7 +420,7 @@ function collectPersonas(personaDir: string, targetDir: string): PersonaFile[] {
);
}
byFileName.set(fileName, sourcePath);
const assets = collectPersonaAssets(sourcePath, json);
const assets = collectPersonaAssets(sourcePath, json, packageRoot);
personas.push({
id,
sourcePath,
Expand Down Expand Up @@ -450,7 +535,7 @@ export function installPersonas(options: PersonaInstallOptions): PersonaInstallR
}

const personaDir = resolvePersonaDir(packageRoot);
const personas = collectPersonas(personaDir, targetDir);
const personas = collectPersonas(personaDir, targetDir, packageRoot);
const selected = resolveRequestedPersonas(personas, options.personaIds ?? []);
mkdirSync(targetDir, { recursive: true });

Expand Down
32 changes: 32 additions & 0 deletions packages/persona-linear-dispatcher/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface PersonaSkill {
id: string;
source: string;
description?: string;
}

export interface PersonaInput {
description: string;
default?: string;
optional?: boolean;
}

export interface LinearDispatcherPersona {
id: string;
intent: string;
tags: string[];
description: string;
integrations: Record<string, unknown>;
skills: PersonaSkill[];
harness: string;
model: string;
systemPrompt: string;
harnessSettings: Record<string, unknown>;
inputs: Record<string, PersonaInput>;
agentsMd: string;
}

declare const persona: LinearDispatcherPersona;

export const linearDispatcherPersona: LinearDispatcherPersona;

export default persona;
20 changes: 20 additions & 0 deletions packages/persona-linear-dispatcher/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

/**
* The linear-dispatcher persona spec. An autonomous Linear issue dispatcher
* that watches for issues in the Ready for Agent state, triages them, dispatches
* codex implementer agents and claude reviewer agents in batches of 5, posts
* comments on issues, and updates state to Agent Implementing.
*
* Source of truth: `personas/linear-dispatcher.json` (with its
* `linear-dispatcher.md` agentsMd sidecar). This compatibility export keeps
* programmatic consumers working while the package also acts as an
* AgentWorkforce installable persona pack.
*/
const persona = require('./personas/linear-dispatcher.json');

export const linearDispatcherPersona = persona;

export default persona;
50 changes: 50 additions & 0 deletions packages/persona-linear-dispatcher/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@agentworkforce/persona-linear-dispatcher",
"version": "0.1.0",
"description": "AgentWorkforce linear-dispatcher persona pack — autonomous Linear issue dispatcher that watches for Ready for Agent issues, triages them, dispatches codex implementer + claude reviewer agent teams in batches of 5, posts comments, and updates issue state. Install with `agentworkforce install`.",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"agentworkforce": {
"personas": "personas"
},
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js",
"default": "./index.js"
},
"./persona.json": "./personas/linear-dispatcher.json",
"./personas/linear-dispatcher.json": "./personas/linear-dispatcher.json",
"./package.json": "./package.json"
},
"files": [
"index.js",
"index.d.ts",
"personas",
"skills"
],
"scripts": {
"test": "node --test test/persona.test.mjs"
},
"keywords": [
"agentworkforce",
"persona",
"linear",
"dispatch",
"orchestration",
"relayfile"
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/AgentWorkforce/workforce",
"directory": "packages/persona-linear-dispatcher"
}
}
Loading
Loading