mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 00:54:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The first agent helps the board define work and hire other agents. > - That agent can have the general role while its instructions require hiring skills. > - Missing skills and blocked schema discovery make valid requests fail. > - Repeated confirmation and invalid waiting guidance can turn these failures into extra runs. > - This PR supplies the required skills, opens read-only schema discovery, and corrects the guidance. > - The agent can complete an authorized hire while company approval and duplicate checks still apply. ## Linked Issues or Issue Description Refs #13068 — the first-task onboarding flow that this change repairs. Refs #12029 — related drift between the sandbox allowlist and bundled hiring guidance. This PR adds schema access; it does not replace the earlier hiring-route fix. **What happened?** A general-role onboarding chief received hiring instructions without the core hiring skills. Sandbox requests to the documented OpenAPI endpoint failed. The agent then guessed question and hire payloads. The persona required new confirmation after validation errors and described waiting states that agents cannot set. **Expected behavior** A direct request authorizes the requested hire. The chief asks only for material missing details, uses valid API payloads, and completes the task. Formal company approval gates still apply. A saved human-input card gives the task a valid waiting state. **Steps to reproduce** 1. Create an onboarding chief with role `general` through the board. 2. Ask it to hire a friendly robot with a supplied name and responsibilities. 3. Check its assigned skills, schema requests, question cards, hire requests, and final task state. **Paperclip version or commit** Reproduced on the first-task onboarding implementation after #13068. The live local verification used this branch at `112f44610`. **Deployment mode** The original failure used a hosted sandbox with legacy Codex ACP. Live verification used an isolated local instance and real `codex_local` execution. Queue and HTTP/2 transport access is covered by automated tests. ## What Changed - Give board-created onboarding chiefs the existing core skills regardless of role. Preserve explicit skill version pins, including aliases. Keep ordinary general-agent defaults and authorization checks. - Allow exactly `GET /api/openapi.json` through both sandbox bridge transports. - Publish validator-tested question, free-text, hire, and waiting examples. Regenerate the runner API reference and capability inventory. - Clarify direct authorization, material ambiguity, and correction of confirmed pre-creation validation failures. Preserve uncertain-outcome reconciliation, duplicate protection, and company approval gates. - Align disposition instructions with agent permissions and the saved human-input waiting path. ## Verification - After rebasing onto current `master`: 69 targeted server tests, 110 queue/HTTP2 bridge tests, and 4 capability inventory tests passed. These cover core skill defaults, version pins, actor restrictions, schema access, published examples, hire validation, idempotency, and approval gates. Waiting recovery tests and live question flows also passed before the rebase. - `pnpm -r typecheck` and `pnpm build` passed again after the rebase. Frozen dependency installation and both generated capability checks passed. - Ran the full `pnpm test:run` suite. The initial run had 14 failed server files due to local database resource limits, a missing built test fixture, and socket failures. All 14 files passed after fixture repair and isolated retries. UI, CLI, workspace packages, database tests, and all 145 serialized server files passed. - Real one-request hiring replay: one hire, one successful run, task done in 2m16s. No repeated approval or recovery escalation. - Real two-turn browser conversation: start with an unspecified hire, then supply a name and friendly robot responsibilities. One clarification card, one hire, two successful runs, task done in 3m27s of execution. No failed writes, confirmation cards, or recovery actions. - Assigned the hired robot a welcome-message task through the browser. It produced a warm message under 100 words and finished in one successful 66-second run, with no questions or recovery actions. - The two-turn flow still asked an optional preferences question and gave a technical final reply. These are remaining presentation limits. - Greptile: 5/5 on `b71f83ba2`, with zero unresolved review threads. Fixed its generator finding and passed 1,655 published-example/runtime API tests plus server typecheck. All latest-head CI checks are green (32 passed; 2 unrelated Storybook checks skipped). The signoff-policy browser test initially timed out while waiting for an approver run. Its shard passed on one rerun without code changes. [CI run](https://github.com/paperclipai/paperclip/actions/runs/34698211049). ## Risks - Onboarding chiefs receive more default skills. Ordinary general agents retain existing defaults, and explicit versions take precedence. - Prompt guidance can affect model behavior. The live replays are examples, not a guarantee that every model follows the guidance. - Retry guidance applies only when validation confirms that nothing was created. Uncertain outcomes still require checking existing agents. - No database migration or new public endpoint. Existing company boundaries, approval gates, and bounded recovery remain in force. ## Model Used OpenAI Codex, model `gpt-6-astra`, with reasoning, tool use, code editing, and live browser verification. The exact context-window size is not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
372 lines
19 KiB
JavaScript
372 lines
19 KiB
JavaScript
import { readFile } from "node:fs/promises";
|
|
import { relative, resolve } from "node:path";
|
|
|
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
|
|
export const capabilityGroups = [
|
|
"hb", "co", "st", "cm", "se", "su", "bl", "dp",
|
|
"ix", "ap", "ar", "er", "rf", "mh", "rs", "wk",
|
|
];
|
|
|
|
const groupPolicies = {
|
|
hb: ["control_plane_owned", ["control_plane_invariant"], ["run:read"]],
|
|
co: ["control_plane_owned", ["control_plane_invariant", "restraint_no_call"], ["task:checkout"]],
|
|
st: ["always_agent_tool", ["agent_tool_contract"], ["task:disposition"]],
|
|
cm: ["always_agent_tool", ["agent_tool_contract"], ["task:comment"]],
|
|
se: ["optional_agent_tool", ["authorization_policy"], ["company:search"]],
|
|
su: ["optional_agent_tool", ["authorization_policy"], ["delegation:manage"]],
|
|
bl: ["always_agent_tool", ["agent_tool_contract", "combined_multi_hop"], ["task:block"]],
|
|
dp: ["always_agent_tool", ["agent_tool_contract"], ["document:write"]],
|
|
ix: ["always_agent_tool", ["agent_tool_contract", "combined_multi_hop"], ["interaction:request"]],
|
|
ap: ["optional_agent_tool", ["authorization_policy", "combined_multi_hop"], ["approval:read"]],
|
|
ar: ["always_agent_tool", ["agent_tool_contract"], ["artifact:register"]],
|
|
er: ["control_plane_owned", ["control_plane_invariant", "restraint_no_call"], ["run:recover"]],
|
|
rf: ["optional_agent_tool", ["authorization_policy", "combined_multi_hop"], ["admin:extended"]],
|
|
mh: ["optional_agent_tool", ["authorization_policy", "combined_multi_hop"], ["delegation:manage"]],
|
|
rs: ["control_plane_owned", ["control_plane_invariant", "restraint_no_call"], ["run:resume"]],
|
|
wk: ["control_plane_owned", ["control_plane_invariant", "combined_multi_hop"], ["run:wake"]],
|
|
};
|
|
|
|
// The skill and eval corpus are the normative behavior contract. These legacy
|
|
// MCP names are retained only as traceability aliases into that contract; they
|
|
// are not a second tool catalog and do not receive independent dispositions.
|
|
const legacyMcpFoldTargets = {
|
|
paperclipMe: "eval:hb-inbox-lite-01",
|
|
paperclipInboxLite: "eval:hb-inbox-lite-01",
|
|
paperclipListAgents: "eval:rf-api-mgr-heartbeat-01",
|
|
paperclipListSkills: "eval:rf-cskill-audit-01",
|
|
paperclipGetAgent: "eval:rf-api-mgr-heartbeat-01",
|
|
paperclipListIssues: "eval:se-q-filters-01",
|
|
paperclipGetIssue: "eval:se-get-issue-01",
|
|
paperclipGetHeartbeatContext: "eval:hb-context-01",
|
|
paperclipListComments: "eval:se-get-issue-01",
|
|
paperclipGetComment: "eval:hb-wake-comment-01",
|
|
paperclipListIssueApprovals: "eval:ap-board-approval-01",
|
|
paperclipListDocuments: "eval:dp-base-revision-01",
|
|
paperclipGetDocument: "eval:dp-base-revision-01",
|
|
paperclipListDocumentRevisions: "eval:dp-base-revision-01",
|
|
paperclipListProjects: "eval:rf-wf-project-setup-01",
|
|
paperclipGetProject: "eval:rf-wf-project-setup-01",
|
|
paperclipGetIssueWorkspaceRuntime: "eval:rf-iws-start-url-01",
|
|
paperclipControlIssueWorkspaceServices: "eval:rf-iws-start-url-01",
|
|
paperclipWaitForIssueWorkspaceService: "eval:rf-iws-target-restart-01",
|
|
paperclipListGoals: "eval:su-parent-goal-01",
|
|
paperclipGetGoal: "eval:su-parent-goal-01",
|
|
paperclipListApprovals: "eval:ap-approval-wake-01",
|
|
paperclipCreateApproval: "eval:ap-board-approval-01",
|
|
paperclipGetApproval: "eval:ap-approval-wake-01",
|
|
paperclipGetApprovalIssues: "eval:ap-approval-wake-01",
|
|
paperclipListApprovalComments: "eval:ap-approval-deny-01",
|
|
paperclipCreateIssue: "eval:su-parent-goal-01",
|
|
paperclipUpdateIssue: "eval:st-done-comment-01",
|
|
paperclipCheckoutIssue: "eval:co-body-contract-01",
|
|
paperclipReleaseIssue: "eval:er-release-01",
|
|
paperclipAddComment: "eval:cm-multiline-01",
|
|
paperclipSuggestTasks: "eval:ix-suggest-tasks-01",
|
|
paperclipAskUserQuestions: "eval:ix-questions-01",
|
|
paperclipRequestConfirmation: "eval:ix-confirmation-plan-01",
|
|
paperclipRequestCheckboxConfirmation: "eval:ix-checkbox-01",
|
|
paperclipUpsertIssueDocument: "eval:dp-plan-doc-01",
|
|
paperclipRestoreIssueDocumentRevision: "eval:dp-base-revision-01",
|
|
paperclipLinkIssueApproval: "eval:ap-board-approval-01",
|
|
paperclipUnlinkIssueApproval: "eval:ap-board-approval-01",
|
|
paperclipApprovalDecision: "eval:ap-approval-wake-01",
|
|
paperclipAddApprovalComment: "eval:ap-approval-deny-01",
|
|
paperclipApiRequest: "eval:rf-api-404-report-01",
|
|
};
|
|
|
|
const sourceHeader = "# GENERATED FILE — DO NOT EDIT. Run pnpm generate:capability-inventory.\n";
|
|
|
|
export function encodeInventory(value) {
|
|
return `${sourceHeader}${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
export function decodeInventory(source) {
|
|
const json = source.startsWith(sourceHeader) ? source.slice(sourceHeader.length) : source;
|
|
return JSON.parse(json);
|
|
}
|
|
|
|
function slug(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[`*_]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "");
|
|
}
|
|
|
|
function classifySkillHeading(heading) {
|
|
const value = heading.toLowerCase();
|
|
if (/(authentication|identity|scope|checkout|lock|retry|dedupe|budget|wake|inbox|pick work|status quick guide|error handling|server-verified external chat)/.test(value)) {
|
|
return "control_plane_owned";
|
|
}
|
|
if (/(artifact|work product|comment|document|confirmation|question|approval follow-up|block|review|final disposition|issue lifecycle)/.test(value)) {
|
|
return "always_agent_tool";
|
|
}
|
|
return "optional_agent_tool";
|
|
}
|
|
|
|
function parseSkillHeadings(text, file) {
|
|
return text.split("\n").flatMap((line, index) => {
|
|
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
|
if (!match) return [];
|
|
const title = match[2];
|
|
return [{
|
|
id: `skill:${file}:${slug(title) || "heading"}:${index + 1}`,
|
|
sourceKind: "skill_heading",
|
|
sourceAnchor: `${file}:${index + 1}`,
|
|
title,
|
|
expectedSemantics: `Skill guidance headed “${title}”.`,
|
|
primaryDisposition: classifySkillHeading(title),
|
|
requiredGrants: [],
|
|
assertionClasses: ["control_plane_invariant"],
|
|
evidenceIds: [`skill:${file}:${index + 1}`],
|
|
}];
|
|
});
|
|
}
|
|
|
|
function parseCase(source, sourceAnchor) {
|
|
const id = /^id:\s*(.+)$/m.exec(source)?.[1]?.trim();
|
|
const title = /^title:\s*(.+)$/m.exec(source)?.[1]?.trim();
|
|
const group = /^group:\s*(\d+)$/m.exec(source)?.[1];
|
|
const fixture = /^fixture:\s*(.+)$/m.exec(source)?.[1]?.trim() ?? null;
|
|
if (!id || !title || !group) throw new Error(`Invalid eval case at ${sourceAnchor}.`);
|
|
const prefix = id.split("-")[0];
|
|
const policy = groupPolicies[prefix];
|
|
if (!policy) throw new Error(`Unknown eval group ${prefix} in ${sourceAnchor}.`);
|
|
const expected = [...source.matchAll(/^\s+- method:\s*(\S+)\n\s+path(?:_pattern)?:\s*(.+)$/gm)]
|
|
.map((match) => `${match[1]} ${match[2].trim()}`);
|
|
const forbidden = [...source.matchAll(/^forbid:\s*$([\s\S]*?)(?=^[a-z_]+:|\n*$)/gm)]
|
|
.flatMap((match) => [...match[1].matchAll(/method:\s*(\S+)/g)].map((method) => method[1]));
|
|
const skillElements = [...source.matchAll(/- file:\s*(.+)\n\s+section:\s*"?(.+?)"?\n\s+lines:\s*"?(.+?)"?$/gm)]
|
|
.map((match) => ({ file: match[1].trim(), section: match[2].trim(), lines: match[3].trim() }));
|
|
return {
|
|
id,
|
|
title,
|
|
group: prefix,
|
|
legacyGroup: Number(group),
|
|
sourceKind: "eval_case",
|
|
sourceAnchor,
|
|
fixture,
|
|
skillElements,
|
|
expectedSemantics: expected.length > 0 ? expected : ["No required HTTP call; evaluate the stated restraint or final state."],
|
|
forbiddenSemantics: forbidden,
|
|
primaryDisposition: policy[0],
|
|
requiredGrants: policy[2],
|
|
assertionClasses: policy[1],
|
|
evidenceIds: [`eval:${id}`],
|
|
};
|
|
}
|
|
|
|
function parseMcpTools(source) {
|
|
return [...source.matchAll(/makeTool\(\s*"([^"]+)"\s*,\s*"([^"]+)"/g)].map((match) => {
|
|
const [name, description] = [match[1], match[2]];
|
|
const foldedInto = legacyMcpFoldTargets[name];
|
|
return {
|
|
id: `mcp:${name}`,
|
|
name,
|
|
description,
|
|
sourceKind: "legacy_mcp_alias",
|
|
sourceAnchor: `packages/mcp-server/src/tools.ts:${source.slice(0, match.index).split("\n").length}`,
|
|
expectedSemantics: description,
|
|
foldedInto,
|
|
evidenceId: `mcp:${name}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
function foldLegacyMcpAliases(evaluations, legacyMcpAliases) {
|
|
const aliasesByTarget = new Map();
|
|
for (const alias of legacyMcpAliases.rows) {
|
|
const aliases = aliasesByTarget.get(alias.foldedInto) ?? [];
|
|
aliases.push(alias);
|
|
aliasesByTarget.set(alias.foldedInto, aliases);
|
|
}
|
|
return {
|
|
...evaluations,
|
|
rows: evaluations.rows.map((row) => {
|
|
const aliases = aliasesByTarget.get(`eval:${row.id}`) ?? [];
|
|
if (aliases.length === 0) return row;
|
|
return {
|
|
...row,
|
|
legacyMcpAliases: aliases.map((alias) => alias.id),
|
|
evidenceIds: [...row.evidenceIds, ...aliases.map((alias) => alias.evidenceId)],
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export async function buildInventories({ repoRoot, evalRoot }) {
|
|
const capabilities = await buildSkillInventory(repoRoot);
|
|
|
|
const evalDirectory = resolve(evalRoot, "skills/paperclip/tests/cases");
|
|
const { readdir } = await import("node:fs/promises");
|
|
const caseNames = (await readdir(evalDirectory)).filter((name) => name.endsWith(".yaml")).sort();
|
|
const evalRows = await Promise.all(caseNames.map(async (name) => parseCase(
|
|
await readFile(resolve(evalDirectory, name), "utf8"),
|
|
`paperclip-evals/paperclip-skill-optimization/skills/paperclip/tests/cases/${name}`,
|
|
)));
|
|
|
|
const legacyMcpAliases = await buildMcpInventory(repoRoot);
|
|
const evaluations = foldLegacyMcpAliases({
|
|
schemaVersion: 2,
|
|
inventoryRole: "normative",
|
|
generatedFrom: "paperclip-evals/paperclip-skill-optimization/skills/paperclip/tests/cases",
|
|
rows: evalRows,
|
|
}, legacyMcpAliases);
|
|
return {
|
|
capabilities,
|
|
evaluations,
|
|
legacyMcpAliases,
|
|
};
|
|
}
|
|
|
|
export async function buildSkillInventory(repoRoot) {
|
|
const skillRoot = resolve(repoRoot, "skills/paperclip");
|
|
const skillFiles = ["SKILL.md", "references/artifacts.md", "references/cases.md", "references/company-skills.md", "references/issue-workspaces.md", "references/routines.md", "references/workflows.md", "references/api-reference.md"];
|
|
const rows = (await Promise.all(skillFiles.map(async (file) => parseSkillHeadings(
|
|
await readFile(resolve(skillRoot, file), "utf8"),
|
|
`skills/paperclip/${file}`,
|
|
)))).flat();
|
|
return {
|
|
schemaVersion: 2,
|
|
inventoryRole: "normative",
|
|
generatedFrom: skillFiles.map((file) => `skills/paperclip/${file}`),
|
|
rows,
|
|
};
|
|
}
|
|
|
|
export async function buildMcpInventory(repoRoot) {
|
|
return {
|
|
schemaVersion: 2,
|
|
inventoryRole: "legacy_alias_index",
|
|
generatedFrom: "packages/mcp-server/src/tools.ts",
|
|
normativeSources: ["capabilities", "evaluations"],
|
|
rows: parseMcpTools(await readFile(resolve(repoRoot, "packages/mcp-server/src/tools.ts"), "utf8")),
|
|
};
|
|
}
|
|
|
|
export function validateInventories(inventories) {
|
|
const errors = [];
|
|
const expectedCounts = { capabilities: 155, evaluations: 106, legacyMcpAliases: 42 };
|
|
const normativeNames = ["capabilities", "evaluations"];
|
|
const normativeRows = new Map();
|
|
const globalNormativeIds = new Set();
|
|
for (const name of normativeNames) {
|
|
const inventory = inventories[name];
|
|
if (!inventory) {
|
|
errors.push(`Missing normative inventory ${name}.`);
|
|
continue;
|
|
}
|
|
if (inventory.schemaVersion !== 2 || inventory.inventoryRole !== "normative") {
|
|
errors.push(`${name} must be a schema version 2 normative inventory.`);
|
|
}
|
|
const seen = new Set();
|
|
for (const row of inventory.rows) {
|
|
if (!row.id || seen.has(row.id)) errors.push(`${name} contains a missing or duplicate id: ${row.id ?? "<missing>"}.`);
|
|
seen.add(row.id);
|
|
const normativeId = name === "evaluations" ? `eval:${row.id}` : row.id;
|
|
if (globalNormativeIds.has(normativeId)) errors.push(`Normative row ${normativeId} is multiply classified.`);
|
|
globalNormativeIds.add(normativeId);
|
|
normativeRows.set(normativeId, row);
|
|
if (!["control_plane_owned", "always_agent_tool", "optional_agent_tool"].includes(row.primaryDisposition)) {
|
|
errors.push(`${name}:${row.id} has invalid primaryDisposition.`);
|
|
}
|
|
if (!row.sourceAnchor || !row.expectedSemantics || !Array.isArray(row.requiredGrants) || !Array.isArray(row.assertionClasses) || !Array.isArray(row.evidenceIds)) {
|
|
errors.push(`${name}:${row.id} is missing normalized traceability fields.`);
|
|
}
|
|
}
|
|
if (expectedCounts[name] && inventory.rows.length !== expectedCounts[name]) {
|
|
errors.push(`${name} expected ${expectedCounts[name]} rows but found ${inventory.rows.length}.`);
|
|
}
|
|
}
|
|
const groups = new Set((inventories.evaluations?.rows ?? []).map((row) => row.group));
|
|
for (const group of capabilityGroups) if (!groups.has(group)) errors.push(`evaluations is missing group ${group}.`);
|
|
if (groups.size !== capabilityGroups.length) errors.push(`evaluations has unexpected groups: ${[...groups].sort().join(", ")}.`);
|
|
|
|
const legacyMcpAliases = inventories.legacyMcpAliases;
|
|
if (!legacyMcpAliases) {
|
|
errors.push("Missing legacy MCP alias index.");
|
|
return errors;
|
|
}
|
|
if (legacyMcpAliases.schemaVersion !== 2 || legacyMcpAliases.inventoryRole !== "legacy_alias_index") {
|
|
errors.push("legacyMcpAliases must be a schema version 2 legacy alias index.");
|
|
}
|
|
const seenAliases = new Set();
|
|
for (const alias of legacyMcpAliases.rows) {
|
|
if (!alias.id || seenAliases.has(alias.id)) {
|
|
errors.push(`legacyMcpAliases contains a missing or duplicate id: ${alias.id ?? "<missing>"}.`);
|
|
}
|
|
seenAliases.add(alias.id);
|
|
if (Object.hasOwn(alias, "primaryDisposition")) {
|
|
errors.push(`legacyMcpAliases:${alias.id} must not define an independent primaryDisposition.`);
|
|
}
|
|
if (!alias.sourceAnchor || !alias.expectedSemantics || !alias.foldedInto || !alias.evidenceId) {
|
|
errors.push(`legacyMcpAliases:${alias.id} is missing folded traceability fields.`);
|
|
continue;
|
|
}
|
|
const target = normativeRows.get(alias.foldedInto);
|
|
if (!target) {
|
|
errors.push(`legacyMcpAliases:${alias.id} folds into unknown normative row ${alias.foldedInto}.`);
|
|
continue;
|
|
}
|
|
const targetAliases = target.legacyMcpAliases ?? [];
|
|
if (targetAliases.filter((id) => id === alias.id).length !== 1) {
|
|
errors.push(`legacyMcpAliases:${alias.id} must be folded exactly once into ${alias.foldedInto}.`);
|
|
}
|
|
if (target.evidenceIds.filter((id) => id === alias.evidenceId).length !== 1) {
|
|
errors.push(`legacyMcpAliases:${alias.id} evidence must be folded exactly once into ${alias.foldedInto}.`);
|
|
}
|
|
}
|
|
if (legacyMcpAliases.rows.length !== expectedCounts.legacyMcpAliases) {
|
|
errors.push(`legacyMcpAliases expected ${expectedCounts.legacyMcpAliases} rows but found ${legacyMcpAliases.rows.length}.`);
|
|
}
|
|
for (const [normativeId, row] of normativeRows) {
|
|
for (const aliasId of row.legacyMcpAliases ?? []) {
|
|
if (!seenAliases.has(aliasId)) errors.push(`${normativeId} references unknown legacy MCP alias ${aliasId}.`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateInventorySchema(inventories, schema) {
|
|
const ajv = new Ajv2020({
|
|
allErrors: true,
|
|
allowUnionTypes: true,
|
|
strict: true,
|
|
strictRequired: false,
|
|
});
|
|
const validate = ajv.compile(schema);
|
|
const errors = [];
|
|
for (const [name, inventory] of Object.entries(inventories)) {
|
|
if (validate(inventory)) continue;
|
|
for (const error of validate.errors ?? []) {
|
|
errors.push(`${name}${error.instancePath || "/"} ${error.message ?? "failed schema validation"}.`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function renderContractModule(inventories) {
|
|
const counts = {
|
|
skillReferenceCapabilities: inventories.capabilities.rows.length,
|
|
evalCases: inventories.evaluations.rows.length,
|
|
normativeRows: inventories.capabilities.rows.length + inventories.evaluations.rows.length,
|
|
legacyMcpAliases: inventories.legacyMcpAliases.rows.length,
|
|
};
|
|
return `// Generated by scripts/generate-capability-inventory.mjs. Do not edit.\n\nexport type CapabilityPrimaryDisposition =\n | "control_plane_owned"\n | "always_agent_tool"\n | "optional_agent_tool";\n\nexport const capabilityInventoryCounts = ${JSON.stringify(counts, null, 2)} as const;\n\nexport const capabilityEvalGroups = ${JSON.stringify(capabilityGroups, null, 2)} as const;\n\nexport const capabilityInventoryContract = {\n schemaVersion: 2,\n normativeInventories: [\n "spec/capability/capabilities.yaml",\n "spec/capability/eval-traceability.yaml",\n ],\n legacyAliasIndexes: ["spec/capability/mcp-tool-map.yaml"],\n primaryDispositions: [\n "control_plane_owned",\n "always_agent_tool",\n "optional_agent_tool",\n ] as const,\n} as const;\n`;
|
|
}
|
|
|
|
export function renderDocumentation(inventories) {
|
|
const groupCounts = Object.fromEntries(capabilityGroups.map((group) => [group, inventories.evaluations.rows.filter((row) => row.group === group).length]));
|
|
const capabilityLines = inventories.capabilities.rows.map((row) => `| ${row.id} | ${row.primaryDisposition} | ${row.sourceAnchor} |`).join("\n");
|
|
const mcpLines = inventories.legacyMcpAliases.rows.map((alias) => {
|
|
const target = inventories.evaluations.rows.find((row) => `eval:${row.id}` === alias.foldedInto);
|
|
return `| ${alias.name} | ${alias.foldedInto} | ${target?.primaryDisposition ?? "unknown"} | ${alias.sourceAnchor} |`;
|
|
}).join("\n");
|
|
return `<!-- GENERATED FILE — DO NOT EDIT. Run pnpm generate:capability-inventory. -->\n\n# Capability Capability Contract\n\nThis generated contract is a self-contained derivative of the Paperclip skill, its seven references, the Paperclip Evals corpus, and the legacy MCP tool surface. It does not import or contact the Paperclip control plane.\n\nThe skill/reference inventory and eval cases are the only normative behavior sources. Paperclip does not use the legacy MCP calls as a production capability surface; all MCP names below are traceability aliases folded into normative eval rows. Their disposition, grants, assertions, and evidence contract are inherited from the target row rather than classified independently.\n\n## Baseline Counts\n\n- Skill/reference headings: ${inventories.capabilities.rows.length}\n- Eval cases: ${inventories.evaluations.rows.length} across ${capabilityGroups.length} groups\n- Total normative rows: ${inventories.capabilities.rows.length + inventories.evaluations.rows.length}\n- Legacy MCP aliases folded into normative rows: ${inventories.legacyMcpAliases.rows.length}\n\n| Eval group | Cases |\n| --- | ---: |\n${Object.entries(groupCounts).map(([group, count]) => `| ${group} | ${count} |`).join("\n")}\n\n## Regeneration\n\n- \`pnpm --dir packages/paperclip-runner generate:capability-inventory\` imports the canonical baselines and rewrites every generated file.\n- \`pnpm --dir packages/paperclip-runner check:capability-inventory\` validates counts, uniqueness, normative dispositions, one-to-one MCP folds, required fields, and generated-file drift without requiring the external eval repository.\n\n## Skill / Reference Rows\n\n| Capability | Primary disposition | Source anchor |\n| --- | --- | --- |\n${capabilityLines}\n\n## Legacy MCP Alias Index\n\nThis is a compatibility/traceability index, not a tool catalog. “Inherited disposition” is shown only to make the normative target easy to audit.\n\n| Legacy MCP name | Folded into normative row | Inherited disposition | Source anchor |\n| --- | --- | --- | --- |\n${mcpLines}\n`;
|
|
}
|
|
|
|
export function packageRelative(repoRoot, path) {
|
|
return relative(repoRoot, path).replaceAll("\\", "/");
|
|
}
|