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
44 changes: 44 additions & 0 deletions skills/rig/samples/471-git-hook-scanner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 471 - Git Hook Scanner

```rig
import { agent, defineTool, p, repair, s } from "rig";

const analyzeHookFile = defineTool("analyzeHookFile", {
description: "Read a git hook file and determine its shebang line and executability.",
parameters: s.object({ hookPath: s.path }),
handler: async ({ hookPath }) => {
const { readFile, stat } = await import("node:fs/promises");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] stat(hookPath) will throw if the hook file doesn't exist (e.g. a path constructed from ls output with trailing whitespace or encoding issues). The error bubbles out of the defineTool handler uncaught, while the inner readFile error is silently swallowed. These two error-handling levels are inconsistent.

💡 Suggested fix

Wrap stat in the same try/catch as readFile, or guard both at the top level:

try {
  const info = await stat(hookPath);
  // ...
} catch {
  return { isExecutable: false, shebang: undefined };
}

const info = await stat(hookPath);
const isExecutable = (info.mode & 0o111) !== 0;
let shebang: string | undefined;
try {
const content = await readFile(hookPath, "utf-8");
const firstLine = content.split("\n")[0] ?? "";
if (firstLine.startsWith("#!")) shebang = firstLine;
} catch {
// unreadable
}
return { isExecutable, shebang };
},
});

// Agent role: scan .git/hooks/ and classify each hook file by type.
const gitHookScanner = agent({
model: "small",
instructions: p`List all files in ${p.bash("ls -la .git/hooks/ 2>/dev/null || echo ''")}. For each hook file (skip .sample files and README), call analyzeHookFile with its full path (.git/hooks/<name>). Classify hookType as one of: pre-commit, commit-msg, post-commit, pre-push, pre-receive, other. Return hooks as a record keyed by hook name, plus activeCount (executable hooks) and totalHooks.`,
output: s.object({
hooks: s.record(s.object({
hookType: s.enum("pre-commit", "commit-msg", "post-commit", "pre-push", "pre-receive", "other"),
shebang: s.optional(s.string),
isExecutable: s.boolean,
})),
activeCount: s.int,
totalHooks: s.int,
}),
tools: [analyzeHookFile],
maxTurns: 6,
addons: repair(),
});

export default gitHookScanner;
```
41 changes: 41 additions & 0 deletions skills/rig/samples/472-merge-strategy-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 472 - Merge Strategy Selector

```rig
import { agent, p, s } from "rig";

// Agent role: analyze git diff --stat to determine the dominant changed area.
const branchDiffAgent = agent({
model: "small",
instructions: p`Analyze the output of ${p.bash("git diff --stat HEAD~1 2>/dev/null || git diff --stat HEAD 2>/dev/null || echo 'no diff'")}. Determine the dominant area of changes: src (source code), test (test files), config (config files), docs (documentation), or mixed (multiple areas equally).`,
output: s.object({
dominantArea: s.enum("src", "test", "config", "docs", "mixed"),
changedFiles: s.int,
}),
});

// Agent role: check for conflicts in the working tree.
const conflictRiskAgent = agent({
model: "small",
instructions: p`Analyze ${p.bash("git status --short 2>/dev/null || echo ''")} and count lines containing conflict markers (<<<<<<, >>>>>>). Return conflictCount and hasConflicts.`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] git status --short does not show conflict markers (<<<<<<<, >>>>>>>); those only appear in file contents after a failed merge. Counting conflict-marker lines in git status --short output will always return 0. To detect conflict markers, grep the working tree instead:

grep -rl '<<<<<<<' . --include='*.ts' --include='*.js' 2>/dev/null | wc -l

output: s.object({
conflictCount: s.int,
hasConflicts: s.boolean,
}),
});

// Agent role: recommend a merge strategy based on diff area and conflict risk.
const mergeSelectorCoordinator = agent({
model: "small",
agents: { branchDiffAgent, conflictRiskAgent },
instructions: "Call branchDiffAgent first to get the dominant area, then call conflictRiskAgent to assess conflict risk. Based on results, choose mergeRecommendation: fast-forward (no conflicts, few files), squash (many small changes, no conflicts), merge (mixed areas or some conflicts), rebase (single area, no conflicts, clean history). Return all combined fields plus a one-sentence rationale.",
output: s.object({
mergeRecommendation: s.enum("fast-forward", "squash", "merge", "rebase"),
dominantArea: s.enum("src", "test", "config", "docs", "mixed"),
conflictCount: s.int,
hasConflicts: s.boolean,
rationale: s.string,
}),
});

export default mergeSelectorCoordinator;
```
42 changes: 42 additions & 0 deletions skills/rig/samples/473-git-tag-message-extractor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 473 - Git Tag Message Extractor

```rig
import { agent, defineTool, p, repair, s } from "rig";

const fetchTagMessage = defineTool("fetchTagMessage", {
description: "Fetch the annotation message for a git tag. Returns empty string if lightweight.",
parameters: s.object({ tagName: s.string }),
handler: async ({ tagName }) => {
const { execSync } = await import("node:child_process");
try {
const output = execSync(`git cat-file tag ${tagName} 2>/dev/null`, { encoding: "utf8" });
const lines = output.split("\n");
const msgStart = lines.findIndex((line: string) => line === "") + 1;
const message = msgStart > 0 ? lines.slice(msgStart).join("\n").trim() : "";
return { isAnnotated: true, message };
} catch {
return { isAnnotated: false, message: "" };
}
},
});

// Agent role: list all git tags and extract their annotation messages.
const gitTagMessageExtractor = agent({
model: "small",
instructions: p`Get the list of tags from ${p.bash("git tag -l 2>/dev/null || echo ''")}. For each tag, call fetchTagMessage to determine if it is annotated and get its message. Return tags as an array with name, optional message, and isAnnotated. Include totalTags and annotatedCount.`,
output: s.object({
tags: s.array(s.object({
name: s.string,
message: s.optional(s.string),
isAnnotated: s.boolean,
})),
totalTags: s.int,
annotatedCount: s.int,
}),
tools: [fetchTagMessage],
maxTurns: 6,
addons: repair(),
});

export default gitTagMessageExtractor;
```
47 changes: 47 additions & 0 deletions skills/rig/samples/474-ts-import-alias-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 474 - TS Import Alias Resolver

```rig
import { agent, defineTool, p, s, steering, repair } from "rig";

const resolveAlias = defineTool("resolveAlias", {
description: "Resolve a TypeScript import alias prefix to its real path using tsconfig paths config.",
parameters: s.object({
aliasPrefix: s.string,
tsconfigContent: s.string,
}),
handler: async ({ aliasPrefix, tsconfigContent }) => {
try {
const tsconfig = JSON.parse(tsconfigContent);
const paths: Record<string, string[]> = tsconfig?.compilerOptions?.paths ?? {};
const key = Object.keys(paths).find((k: string) => k.startsWith(aliasPrefix.replace(/\*$/, "")));
if (key) {
const targets = paths[key] ?? [];
return { realPath: targets[0]?.replace(/\*$/, "") ?? null, found: true };
}
return { realPath: null, found: false };
} catch {
return { realPath: null, found: false };
}
},
});

// Agent role: resolve TypeScript import aliases by reading tsconfig paths and grepping src/ for usages.
const tsImportAliasResolver = agent({
model: "small",
instructions: p`Read the project tsconfig: ${p.readOptional("tsconfig.json", "{}")}. Then check grep output: ${p.bash("grep -rn \"from '@\" src/ --include='*.ts' 2>/dev/null | head -100 || echo ''")}. For each unique alias prefix found (e.g. @utils/, @lib/), call resolveAlias with the prefix and the tsconfig content. Count usages per alias and list the files. Return aliases as a record keyed by prefix, plus totalAliasUsages and unmappedAliases (aliases not found in tsconfig).`,
output: s.object({
aliases: s.record(s.object({
realPath: s.optional(s.string),
usageCount: s.int,
files: s.array(s.string),
})),
totalAliasUsages: s.int,
unmappedAliases: s.array(s.string),
}),
tools: [resolveAlias],
maxTurns: 6,
addons: [steering(), repair()],
});

export default tsImportAliasResolver;
```
53 changes: 53 additions & 0 deletions skills/rig/samples/475-json-schema-property-counter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 475 - JSON Schema Property Counter

```rig
import { agent, defineTool, p, repair, s } from "rig";

const countPropertiesAtLevel = defineTool("countPropertiesAtLevel", {
description: "Recursively count JSON Schema properties at each depth level.",
parameters: s.object({ schemaJson: s.string }),
handler: async ({ schemaJson }) => {
const schema = JSON.parse(schemaJson);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The agent's instructions use p.readInput("schemaFile") to inline the file content into the prompt, but then passes it again to countPropertiesAtLevel as a raw JSON string that gets JSON.parsed in the tool handler. For large schemas this doubles the token cost — the full schema appears in both the prompt and the tool call. A defineTool that accepts a filePath: s.path and reads the file itself would be cleaner and avoid the double-serialization.

💡 Suggested alternative
const countPropertiesAtLevel = defineTool('countPropertiesAtLevel', {
  parameters: s.object({ filePath: s.path }),
  handler: async ({ filePath }) => {
    const { readFile } = await import('node:fs/promises');
    const schema = JSON.parse(await readFile(filePath, 'utf-8'));
    // ... same walk logic
  }
});

Then the instruction can just say: call countPropertiesAtLevel with the schemaFile path.

const levels: Record<string, number> = {};
let total = 0;
let maxDepth = 0;
let hasRequired = false;

function walk(node: unknown, depth: number): void {
if (!node || typeof node !== "object") return;
const obj = node as Record<string, unknown>;
if (obj["required"]) hasRequired = true;
if (obj["properties"] && typeof obj["properties"] === "object") {
const props = obj["properties"] as Record<string, unknown>;
const count = Object.keys(props).length;
levels[String(depth)] = (levels[String(depth)] ?? 0) + count;
total += count;
if (depth > maxDepth) maxDepth = depth;
for (const val of Object.values(props)) walk(val, depth + 1);
}
if (obj["items"]) walk(obj["items"], depth);
}

walk(schema, 0);
return { levels, totalProperties: total, maxDepth, hasRequired };
},
});

// Agent role: count JSON schema properties at each nesting depth given a schema file path.
const jsonSchemaPropertyCounter = agent({
model: "small",
input: s.object({ schemaFile: s.string }),
instructions: p`Read the JSON schema file at the path provided in input.schemaFile: ${p.readInput("schemaFile")}. Pass its full content to countPropertiesAtLevel. Return levels (depth->count), totalProperties, maxDepth, and hasRequired.`,
output: s.object({
levels: s.record(s.int),
totalProperties: s.int,
maxDepth: s.int,
hasRequired: s.boolean,
}),
tools: [countPropertiesAtLevel],
maxTurns: 4,
addons: repair(),
});

export default jsonSchemaPropertyCounter;
```
62 changes: 62 additions & 0 deletions skills/rig/samples/476-changelog-entry-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 476 - Changelog Entry Workflow

```rig
import { agent, p, s } from "rig";

// Agent role: collect recent git commits as raw log lines.
const commitCollector = agent({
model: "small",
instructions: p`Run ${p.bash("git log --oneline -20 2>/dev/null || echo ''")} and return each line as a commit entry with hash and message.`,
output: s.object({
commits: s.array(s.object({ hash: s.string, message: s.string })),
}),
});

// Agent role: classify each commit by conventional commit category.
const commitClassifier = agent({
model: "small",
input: s.object({ commits: s.array(s.object({ hash: s.string, message: s.string })) }),
instructions: "For each commit in input.commits, classify it as feat, fix, chore, docs, or other based on the commit message prefix. Return classified commits array and a categories count record.",
output: s.object({
classified: s.array(s.object({
hash: s.string,
message: s.string,
category: s.enum("feat", "fix", "chore", "docs", "other"),
})),
categories: s.record(s.int),
}),
});

// Agent role: format classified commits into a markdown changelog entry.
const changelogFormatter = agent({
model: "small",
input: s.object({
classified: s.array(s.object({
hash: s.string,
message: s.string,
category: s.enum("feat", "fix", "chore", "docs", "other"),
})),
categories: s.record(s.int),
}),
instructions: "Format input.classified into a markdown changelog entry grouped by category. Return changelogEntry (markdown string), commitCount, and categories (the input categories record).",
output: s.object({
changelogEntry: s.string,
commitCount: s.int,
categories: s.record(s.int),
}),
});

// Agent role: orchestrate changelog generation by running collector, classifier, then formatter.
const changelogCoordinator = agent({
model: "small",
agents: { commitCollector, commitClassifier, changelogFormatter },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The coordinator passes data between subagents via prompt instructions ("pass the commits array to commitClassifier"), but commitClassifier and changelogFormatter declare typed input schemas. The coordinator should use the input: field of those agents rather than relying on the LLM to faithfully relay the data through prose instructions — that's exactly what the structured input/output pipeline is for.

💡 Pattern note

The rig docs call this chaining via the coordinator's agent calls. Each subagent call passes structured output as the next agent's input, reducing hallucination risk. The current instruction prose "pass the commits array" works but is fragile for larger payloads.

instructions: "Step 1: call commitCollector to get recent commits. Step 2: pass the commits array to commitClassifier. Step 3: pass classified and categories to changelogFormatter. Return changelogFormatter's output.",
output: s.object({
changelogEntry: s.string,
commitCount: s.int,
categories: s.record(s.int),
}),
});

export default changelogCoordinator;
```
44 changes: 44 additions & 0 deletions skills/rig/samples/477-node-module-path-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 477 - Node Module Path Resolver

```rig
import { agent, defineTool, p, repair, s } from "rig";

const resolveModulePath = defineTool("resolveModulePath", {
description: "Resolve the filesystem path of an installed npm package using node:module.",
parameters: s.object({ moduleName: s.string }),
handler: async ({ moduleName }) => {
const { createRequire } = await import("node:module");
const { isBuiltin } = await import("node:module");
if (isBuiltin(moduleName)) {
return { resolvedPath: null, isBuiltin: true };
}
try {
const req = createRequire(process.cwd() + "/index.js");
const resolved = req.resolve(moduleName);
return { resolvedPath: resolved, isBuiltin: false };
} catch {
return { resolvedPath: null, isBuiltin: false };
}
},
});

// Agent role: resolve an npm module path and extract its package.json metadata.
const nodeModulePathResolver = agent({
model: "small",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] p.readOptional("package.json", "{}") always reads the project root package.json, not the resolved module's own package.json. Metadata returned (name, version, main, types) will reflect the host project, not the dependency.

💡 Suggested fix

Use require.resolve(moduleName + '/package.json') directly in the resolveModulePath tool and return the parsed fields there, eliminating the misleading p.readOptional call.

input: s.object({ moduleName: s.string }),
instructions: p`The module to resolve is provided in input.moduleName. Call resolveModulePath with it. If resolvedPath is returned, find and read the package.json near that path: ${p.readOptional("package.json", "{}")}. Extract name, version, main, and types fields. Return resolvedPath, packageName, version, main, types, and isBuiltin.`,
output: s.object({
resolvedPath: s.string,
packageName: s.string,
version: s.string,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] resolvedPath is typed as s.string (non-optional) in the output schema, but the tool returns resolvedPath: null for built-in modules or resolution failures. The agent will attempt to fill a required string field with null, likely triggering a repair loop or returning an invalid "null" string.

💡 Suggested fix

Change the output schema to reflect the nullable reality:

resolvedPath: s.optional(s.string),

And update the instructions to handle the built-in case gracefully.

main: s.optional(s.string),
types: s.optional(s.string),
isBuiltin: s.boolean,
}),
tools: [resolveModulePath],
maxTurns: 4,
addons: repair(),
});

export default nodeModulePathResolver;
```
34 changes: 34 additions & 0 deletions skills/rig/samples/478-source-file-checksum-reporter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 478 - Source File Checksum Reporter

```rig
import { agent, defineTool, p, s, steering, repair } from "rig";

const computeChecksum = defineTool("computeChecksum", {
description: "Compute the MD5 checksum and byte size of a file.",
parameters: s.object({ filePath: s.path }),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] MD5 is not collision-resistant and is deprecated for integrity use cases. Since this is a sample demonstrating node:crypto, it's better to use sha256 — same API, more correct for any real checksum use.

💡 One-line fix
const checksum = createHash('sha256').update(buf).digest('hex');

Samples teach patterns; teaching MD5 normalises a weak algorithm when sha256 costs nothing extra.

handler: async ({ filePath }) => {
const { createHash } = await import("node:crypto");
const { readFile } = await import("node:fs/promises");
const buf = await readFile(filePath);
const checksum = createHash("md5").update(buf).digest("hex");
return { checksum, sizeBytes: buf.length };
},
});

// Agent role: compute MD5 checksums for all TypeScript source files and report totals.
const sourceFileChecksumReporter = agent({
model: "small",
instructions: p`Find TypeScript source files with ${p.bash("find src -name '*.ts' -not -path '*/node_modules/*' 2>/dev/null | head -50 || echo ''")}. For each file path, call computeChecksum. Return files as a record keyed by path with checksum and sizeBytes, plus totalFiles, totalBytes, and largestFile (the path with the most bytes, or omit if none).`,
output: s.object({
files: s.record(s.object({ checksum: s.string, sizeBytes: s.int })),
totalFiles: s.int,
totalBytes: s.int,
largestFile: s.optional(s.string),
}),
tools: [computeChecksum],
maxTurns: 8,
addons: [steering(), repair()],
});

export default sourceFileChecksumReporter;
```
Loading
Loading