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
43 changes: 43 additions & 0 deletions skills/rig/samples/150-git-hotspot-analyzer-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 150 - Git Hotspot Analyzer V3

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

// Agent role: identify hot-spot files by git churn and classify risk level.
const gitHotspotAnalyzerV3 = agent({
model: "small",
addons: steering({ message: "Focus on files that appear most frequently. Assign riskLevel based on churnScore: >20=critical, >10=high, >5=medium, else low." }),
instructions: p`Analyze git history to find frequently changed files and classify their risk.

Recently changed files (with churn counts):
${p.bash("git log --name-only --format='' HEAD~50..HEAD | grep -v '^$' | sort | uniq -c | sort -rn | head -30")}

Top contributors overall:
${p.bash("git shortlog -sn HEAD~50..HEAD 2>/dev/null | head -10")}

Use the countChurn tool to parse the churn output for each file. For each file, determine
the churnScore, assign topContributors from the shortlog output, and classify riskLevel.
Return a record keyed by file path with the declared output shape.`,
tools: [
defineTool("countChurn", {
description: "Parse a line of uniq -c output to extract count and filename",
parameters: s.object({ line: s.string }),
handler({ line }) {
const m = line.trim().match(/^(\d+)\s+(.+)$/);
if (!m) return { count: 0, file: "" };
return { count: parseInt(m[1], 10), file: m[2].trim() };
},
}),
],
output: s.record(
s.object({
churnScore: s.int,
topContributors: s.array(s.string),
riskLevel: s.enum("low", "medium", "high", "critical"),
})
),
});

export default gitHotspotAnalyzerV3;
```
56 changes: 56 additions & 0 deletions skills/rig/samples/151-loc-statistics-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 151 - Loc Statistics V2

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

// Agent role: gather lines-of-code statistics per file extension.
const locStatisticsV2 = agent({
model: "small",
instructions: p`Count lines of code per file extension in this workspace.

TypeScript files found:
${p.bash("find . -name '*.ts' -not -path '*/node_modules/*' | head -50")}

Line counts for TypeScript files:
${p.bash("find . -name '*.ts' -not -path '*/node_modules/*' | head -20 | xargs wc -l 2>/dev/null | tail -1")}

JavaScript files found:
${p.bash("find . -name '*.js' -not -path '*/node_modules/*' | head -20")}

Use the aggregateExtension tool to compute totals. Then classify complexity for each
extension: xlarge > 10000 lines, large > 5000, medium > 1000, small otherwise.
Return a record keyed by extension (e.g. ".ts", ".js") with lineCount, fileCount,
and complexity.`,
tools: [
defineTool("aggregateExtension", {
description: "Count lines in files matching a given extension",
parameters: s.object({ extension: s.string, sampleFile: s.string }),
async handler({ extension, sampleFile }) {
const { execSync } = await import("node:child_process");
try {
const count = execSync(
`find . -name '*${extension}' -not -path '*/node_modules/*' | xargs wc -l 2>/dev/null | tail -1 | awk '{print $1}'`,
{ encoding: "utf-8" }
).trim();
const files = execSync(

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.

[/grill-with-docs] The extension parameter in the shell command is interpolated without any sanitisation — passing extension = '.ts; rm -rf .') would execute arbitrary commands. Since this is a sample demonstrating defineTool, the parameter should be validated or escaped before being interpolated into a shell string.

💡 Suggested fix

Add an allowlist check before the shell call:

async handler({ extension, sampleFile }) {
  if (!/^\.\w+$/.test(extension)) {
    return { extension, lineCount: 0, fileCount: 0, sampleFile };
  }
  // ... rest unchanged
}

Samples are teaching material — they should model safe shell-interpolation practices.

`find . -name '*${extension}' -not -path '*/node_modules/*' | wc -l`,
{ encoding: "utf-8" }
).trim();
return { extension, lineCount: parseInt(count) || 0, fileCount: parseInt(files) || 0, sampleFile };
} catch {
return { extension, lineCount: 0, fileCount: 0, sampleFile };
}
},
}),
],
output: s.record(
s.object({
lineCount: s.int,
fileCount: s.int,
complexity: s.enum("small", "medium", "large", "xlarge"),
})
),
});

export default locStatisticsV2;
```
39 changes: 39 additions & 0 deletions skills/rig/samples/152-import-cycle-detector-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 152 - Import Cycle Detector V3

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

// Agent role: detect circular import cycles in TypeScript source and classify severity.
const importCycleDetectorV3 = agent({
model: "small",
maxTurns: 3,
addons: repair(),
instructions: p`Detect circular import cycles in this TypeScript project.

Circular dependency analysis:
${p.bash("npx madge --circular --extensions ts src 2>/dev/null || echo 'No cycles found or madge not available'")}

TypeScript configuration:
${p.readOptional("tsconfig.json", "{}")}

Analyze the output above. Each cycle is a group of files that import each other in a
circle. Classify severity: high if the cycle involves more than 3 files or core modules,
medium for 2-3 files, low for simple two-file cycles.

Return hasCycles (true if any cycles found), the cycles array with path (array of file
strings in the cycle) and severity, and totalCycles count.`,
output: s.object({
hasCycles: s.boolean,
cycles: s.array(
s.object({
path: s.array(s.string),
severity: s.enum("high", "medium", "low"),
})
),
totalCycles: s.int,
}),
});

export default importCycleDetectorV3;
```
39 changes: 39 additions & 0 deletions skills/rig/samples/153-coverage-badge-updater-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 153 - Coverage Badge Updater V2

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

// Agent role: read coverage summary and update README with shields.io badge links.
const coverageBadgeUpdaterV2 = agent({
model: "small",
instructions: p`Read coverage data and update the README with shields.io coverage badges.

Coverage summary (JSON):
${p.readOptional("coverage/coverage-summary.json", "{}")}

Current README:
${p.readOptional("README.md", "# Project\n")}

Compute the coverage percentage for each category (statements, branches, functions, lines)
from the total section. Compute overallPct as the average. Classify rating: green >= 80%,
yellow >= 60%, red < 60%.

Generate shields.io badge markdown for each category using URL format:
https://img.shields.io/badge/coverage-XX%25-green

Write the updated README with badges added at the top using p.write.

${p.write("README.md", "<!-- badges will be written by agent -->")}

Return coverageByCategory (record of category to percentage), overallPct, rating, and
badgesWritten (true if README was updated).`,
output: s.object({
coverageByCategory: s.record(s.number),

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.

[/grill-with-docs] p.write("README.md", "<!-- badges will be written by agent -->") uses a hardcoded placeholder string — the agent can't write the real badge content because the content is only known after the LLM produces it. Use p.writeOutput("badgesMarkdown", "README.md") instead, and add a badgesMarkdown: s.string field to the output schema so the harness writes the generated content post-completion.

💡 Suggested fix

The invariant (documented in INV:p-write-no-path) is that p.write contributes a write instruction to the prompt but does not execute it with dynamic content. For dynamic content, use p.writeOutput(outputField, path):

instructions: p`...Generate badge markdown.
${p.writeOutput("badgesMarkdown", "README.md")}`,
output: s.object({
  coverageByCategory: s.record(s.number),
  overallPct: s.number,
  rating: s.enum("green", "yellow", "red"),
  badgesMarkdown: s.string,   // harness writes this to README.md
  badgesWritten: s.boolean,
}),

Existing samples 123, 133, 143 all use p.writeOutput for this pattern.

overallPct: s.number,
rating: s.enum("green", "yellow", "red"),
badgesWritten: s.boolean,
}),
});

export default coverageBadgeUpdaterV2;
```
41 changes: 41 additions & 0 deletions skills/rig/samples/154-dep-license-auditor-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 154 - Dep License Auditor V2

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

// Agent role: audit npm dependency licenses and flag copyleft packages.
const depLicenseAuditorV2 = agent({
model: "small",
instructions: p`Audit the licenses of npm dependencies and classify each one.

Installed dependencies (JSON tree):
${p.bash("npm ls --json --depth=0 2>/dev/null || echo '{}'")}

Package dependency names:
${p.bash("node -e \"try{const p=require('./package.json');console.log(JSON.stringify(Object.keys(p.dependencies||{})))}catch(e){console.log('[]')}\"")}

Package license fields from node_modules:
${p.bash("node -e \"const fs=require('fs');const d='./node_modules';if(fs.existsSync(d)){const pkgs=fs.readdirSync(d).filter(x=>!x.startsWith('.'));pkgs.slice(0,30).forEach(p=>{try{const m=JSON.parse(fs.readFileSync(d+'/'+p+'/package.json','utf8'));console.log(p+':'+m.license)}catch(e){}});}\" 2>/dev/null || echo 'unavailable'")}

For each dependency, determine its license. Classify as:
- permissive: MIT, ISC, BSD, Apache, CC0, Unlicense, 0BSD
- copyleft: GPL, LGPL, AGPL, MPL, EUPL, CC-BY-SA
- unknown: anything else or unspecified

Return the packages array with name, license, and classification, hasCopyleft (true if any
copyleft found), and totalCount.`,
output: s.object({
packages: s.array(
s.object({
name: s.string,
license: s.string,
classification: s.enum("permissive", "copyleft", "unknown"),
})
),
hasCopyleft: s.boolean,
totalCount: s.int,
}),
});

export default depLicenseAuditorV2;
```
56 changes: 56 additions & 0 deletions skills/rig/samples/155-test-coverage-mapper-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 155 - Test Coverage Mapper V2

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

// Agent role: map source files to their test files using filename heuristics.
const testCoverageMapperV2 = agent({
model: "small",
maxTurns: 2,
addons: repair(),
instructions: p`Map TypeScript source files to their corresponding test files.

Source files (non-test TypeScript):
${p.bash("find . -name '*.ts' -not -name '*.test.ts' -not -name '*.spec.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | head -30")}

Test files:
${p.bash("find . -name '*.test.ts' -o -name '*.spec.ts' | grep -v node_modules | head -30")}

Use the matchTestFile tool for each source file to find its corresponding test file using
filename heuristics (e.g., src/foo.ts -> src/foo.test.ts or tests/foo.test.ts).

Return a record keyed by source file path with coverage ("covered" if test found,
"uncovered" if none, "partial" if indirect match), testFiles (array of matched test paths),
and reason explaining the determination.`,
tools: [
defineTool("matchTestFile", {
description: "Find test files that match a source file using naming heuristics",
parameters: s.object({ sourceFile: s.string, testFiles: s.array(s.string) }),
handler({ sourceFile, testFiles }) {
const base = sourceFile.replace(/\.tsx?$/, "").replace(/^.*\//, "");
const direct = testFiles.filter((t) =>
t.includes(`${base}.test`) || t.includes(`${base}.spec`)
);
const partial = direct.length === 0
? testFiles.filter((t) => t.toLowerCase().includes(base.toLowerCase()))
: [];
return {
matched: direct,
partial,
coverage: direct.length > 0 ? "covered" : partial.length > 0 ? "partial" : "uncovered",
};
},
}),
],
output: s.record(
s.object({
coverage: s.enum("covered", "uncovered", "partial"),
testFiles: s.array(s.string),
reason: s.string,
})
),
});

export default testCoverageMapperV2;
```
64 changes: 64 additions & 0 deletions skills/rig/samples/156-regex-pattern-tester.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 156 - Regex Pattern Tester

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

// Agent role: run regex patterns against test cases and report pass/fail results.
const regexPatternTester = agent({
model: "small",
addons: repair(),
input: s.object({
patterns: s.array(
s.object({
name: s.string,
regex: s.string,
testCases: s.array(
s.object({
input: s.string,
expected: s.boolean,
})
),
})
),
}),
instructions: p`Run each regex pattern against its test cases and report results.

Input patterns and test cases:
${p.json("input")}

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.

[/grill-with-docs] p.json("input") passes the string "input" to JSON.stringify — the literal string ends up in the prompt, not the caller-supplied input object. Use p.inputField("patterns") to reference the runtime input field in prompt prose.

💡 Suggested fix

Replace:

${p.json("input")}

With:

Input patterns and test cases: ${p.inputField("patterns")}

p.json(value) is a JSON.stringify helper for concrete values already in scope — not a reference to the runtime input bag. The current code serialises the string "input" into the prompt, so the model never sees the caller-supplied patterns array.


Use the runRegexTest tool for each (pattern, input) pair. Then build the results array
with patternName, input, expected, actual (from tool), and passed (expected === actual).
Count passCount and failCount. Set allPassed to true only if failCount is 0.`,
tools: [
defineTool("runRegexTest", {
description: "Test a regex pattern against an input string",
parameters: s.object({ pattern: s.string, input: s.string }),
handler({ pattern, input }) {
try {
const matched = new RegExp(pattern).test(input);
return { matched };
} catch {
return { matched: false };
}
},
}),
],
output: s.object({
results: s.array(
s.object({
patternName: s.string,
input: s.string,
expected: s.boolean,
actual: s.boolean,
passed: s.boolean,
})
),
passCount: s.int,
failCount: s.int,
allPassed: s.boolean,
}),
});

export default regexPatternTester;
```
32 changes: 32 additions & 0 deletions skills/rig/samples/157-commit-churn-classifier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 157 - Commit Churn Classifier

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

// Agent role: classify files by commit churn frequency and assign a risk level.
const commitChurnClassifier = agent({
model: "small",
addons: steering({ message: "Be precise: assign riskLevel based on churnCount: >20=critical, >10=volatile, >5=active, else stable." }),
instructions: p`Classify repository files by how frequently they are committed (churn).

File churn counts from last 100 commits (count file):
${p.bash("git log --name-only --format='' HEAD~100..HEAD 2>/dev/null | grep -v '^$' | sort | uniq -c | sort -rn | head -30")}

For each file in the output above, parse the churn count and assign a riskLevel:
- critical: churnCount > 20
- volatile: churnCount > 10
- active: churnCount > 5
- stable: churnCount <= 5

Return a record keyed by file path with churnCount (integer) and riskLevel.`,
output: s.record(
s.object({
churnCount: s.int,
riskLevel: s.enum("stable", "active", "volatile", "critical"),
})
),
});

export default commitChurnClassifier;
```
Loading
Loading