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
4 changes: 3 additions & 1 deletion skills/rig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output,
| Numeric schema choice | `s.int` for counts/line numbers; `s.number` for measurements and ratios |
| Optional versus nullable | `s.optional(shape)` for omission; `s.nullable(shape)` for explicit `null` |
| Custom model-callable operation | `defineTool(name, { description, parameters, handler })` |
| Structured-output retries | `maxTurns` on the agent plus `addons: repair()` |
| Structured-output retries | `maxTurns` on the agent plus `addons: [repair()]` |
| Retry with final-turn warning | `addons: [steering(), repair()]` in that order |

Prompt intents are declarative instructions, not in-process operations. Prefer file intents over `cat` and workspace paths over large in-memory strings.
Expand All @@ -65,6 +65,8 @@ Prompt intents are declarative instructions, not in-process operations. Prefer f
- `defineTool` uses the two-argument config form. Use `s.object({ ... })` for object-shaped parameters — plain `{ key: s.string }` loses handler arg type inference. Arrow callbacks in handlers must have explicit type annotations: `.map((line: string) => ...)`.
- `repair()` takes no arguments. Turn budgets belong on the agent spec or invocation.
- Stable settings belong in `agent({ ... })`; per-run `model`, `maxTurns`, `timeout`, and `signal` belong on invocation; `agent.use()` accepts only addons.
- Valid `agent()` fields: `name`, `instructions`, `input`, `output`, `model`, `maxTurns`, `addons`, `agents`, `systemMessage`, `tools`. Misspelled keys (e.g. `instructions2`) are silently dropped; the linter flags them.
- Handler functions that return string literals must use `as const` to preserve the literal type for enum schema comparison.

## Runnable output

Expand Down
4 changes: 4 additions & 0 deletions skills/rig/eslint/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import noObjectLiteralRecord from "./rules/no-object-literal-record.js";
import repairNoArgs from "./rules/repair-no-args.js";
import noImplicitAnyInToolHandler from "./rules/no-implicit-any-in-tool-handler.js";
import preferPGlobOverBashFind from "./rules/prefer-p-glob-over-bash-find.js";
import noInvalidAgentFields from "./rules/no-invalid-agent-fields.js";
import enumReturnNeedsAsConst from "./rules/enum-return-needs-as-const.js";

export default {
meta: {
Expand All @@ -16,5 +18,7 @@ export default {
"repair-no-args": repairNoArgs,
"no-implicit-any-in-tool-handler": noImplicitAnyInToolHandler,
"prefer-p-glob-over-bash-find": preferPGlobOverBashFind,
"no-invalid-agent-fields": noInvalidAgentFields,
"enum-return-needs-as-const": enumReturnNeedsAsConst,
},
};
3 changes: 2 additions & 1 deletion skills/rig/eslint/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { scanTokens as scanNoObjectLiteralRecord } from "./rules/no-object-liter
import { scanTokens as scanRepairNoArgs } from "./rules/repair-no-args.js";
import { scanTokens as scanNoImplicitAnyInToolHandler } from "./rules/no-implicit-any-in-tool-handler.js";
import { scanTokens as scanPreferPGlobOverBashFind } from "./rules/prefer-p-glob-over-bash-find.js";
import { scanTokens as scanNoInvalidAgentFields } from "./rules/no-invalid-agent-fields.js";

const ignoredDirectories = new Set([".git", "node_modules"]);
const tokenRules = [scanDefineToolArgCount, scanAgentsMustBeObject, scanNoObjectLiteralRecord, scanRepairNoArgs, scanNoImplicitAnyInToolHandler, scanPreferPGlobOverBashFind];
const tokenRules = [scanDefineToolArgCount, scanAgentsMustBeObject, scanNoObjectLiteralRecord, scanRepairNoArgs, scanNoImplicitAnyInToolHandler, scanPreferPGlobOverBashFind, scanNoInvalidAgentFields];

function tokenize(source) {
const tokens = [];
Expand Down
69 changes: 69 additions & 0 deletions skills/rig/eslint/rules/enum-return-needs-as-const.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export default {
meta: {
type: "suggestion",
docs: {
description:
"Require 'as const' on string literal returns inside defineTool handlers to prevent type widening",
},
fixable: "code",
schema: [],
messages: {
needsAsConst:
"String literal returned without 'as const'. TypeScript widens the type to string; add 'as const' to preserve the literal type.",
},
},
create(context) {
/**
* Walk up the ancestor chain to determine whether this return statement is
* directly inside a `handler:` property function body (arrow or regular).
*/
function insideHandlerProp(node) {
let current = node.parent;
while (current) {
const t = current.type;
if (
t === "ArrowFunctionExpression"
|| t === "FunctionExpression"
|| t === "FunctionDeclaration"
) {
const parent = current.parent;
if (
parent?.type === "Property"
&& !parent.computed
&& parent.key?.type === "Identifier"
&& parent.key.name === "handler"
) {
return true;
}
// Stop at any other function boundary
return false;
}
current = current.parent;
}
return false;
}

return {
ReturnStatement(node) {
const { argument } = node;
if (
!argument
|| argument.type !== "Literal"
|| typeof argument.value !== "string"
) {
return;
}

if (!insideHandlerProp(node)) return;

context.report({
node: argument,
messageId: "needsAsConst",
fix(fixer) {
return fixer.replaceText(argument, `${argument.raw} as const`);
},
});
},
};
},
};
138 changes: 138 additions & 0 deletions skills/rig/eslint/rules/no-invalid-agent-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const validAgentFields = new Set([
"name",
"instructions",
"input",
"output",
"model",
"maxTurns",
"addons",
"agents",
"systemMessage",
"tools",
]);

function findClosing(tokens, openingIndex) {
const pairs = { "(": ")", "{": "}", "[": "]" };
const open = tokens[openingIndex]?.value;
const close = pairs[open];
if (!close) return undefined;
let depth = 0;
for (let i = openingIndex; i < tokens.length; i += 1) {
if (tokens[i].value === open) depth += 1;
if (tokens[i].value === close) depth -= 1;
if (depth === 0) return i;
}
return undefined;
}

export function scanTokens(tokens) {
const problems = [];

for (let index = 0; index <= tokens.length - 2; index += 1) {
const [fn, openParen] = tokens.slice(index, index + 2);
if (
tokens[index - 1]?.value === "."
|| fn.value !== "agent"
|| openParen.value !== "("
) {
continue;
}

const closeParenIndex = findClosing(tokens, index + 1);
if (closeParenIndex === undefined) continue;

// Find the opening brace of the first argument
let braceIndex = index + 2;
while (braceIndex < closeParenIndex && tokens[braceIndex]?.value !== "{") {
braceIndex += 1;
}
if (braceIndex >= closeParenIndex) continue;

const closeBraceIndex = findClosing(tokens, braceIndex);
if (closeBraceIndex === undefined || closeBraceIndex > closeParenIndex) continue;

// Walk the top-level entries of the object literal
let depth = 0;
for (let i = braceIndex + 1; i < closeBraceIndex; i += 1) {
const t = tokens[i];
if (t.value === "(" || t.value === "{" || t.value === "[") {
depth += 1;
continue;
}
if (t.value === ")" || t.value === "}" || t.value === "]") {
depth -= 1;
continue;
}
if (depth !== 0) continue;

// A bare identifier followed by ":" is a property key
const next = tokens[i + 1];
if (
/^[A-Za-z_$][\w$]*$/.test(t.value)
&& next?.value === ":"
&& !validAgentFields.has(t.value)
) {
problems.push({
start: t.start,
end: t.end,
message: `"${t.value}" is not a valid agent spec field. Valid fields: ${[...validAgentFields].join(", ")}.`,
kind: "no-invalid-agent-fields",
edits: [],
});
}
}
}

return problems;
}

export default {
meta: {
type: "problem",
docs: {
description: "Disallow unknown fields on the agent() spec object",
},
fixable: null,
schema: [],
messages: {
invalidField:
"\"{{field}}\" is not a valid agent spec field. Valid fields: {{valid}}.",
},
},
create(context) {
return {
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== "Identifier"
|| callee.name !== "agent"
|| args.length === 0
|| args[0].type !== "ObjectExpression"
) {
return;
}

for (const prop of args[0].properties) {
if (prop.type !== "Property") continue; // skip spread
if (prop.computed) continue; // skip computed keys
const keyName =
prop.key.type === "Identifier"
? prop.key.name
: prop.key.type === "Literal"
? String(prop.key.value)
: null;
if (keyName === null || validAgentFields.has(keyName)) continue;

context.report({
node: prop.key,
messageId: "invalidField",
data: {
field: keyName,
valid: [...validAgentFields].join(", "),
},
});
}
},
};
},
};
34 changes: 34 additions & 0 deletions skills/rig/references/linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ The rule autofixes simple `find DIR -name PATTERN` commands (no other predicates

Use `p.bash` when you need shell command output (counts, content, sorting, etc.); use `p.glob` when you want the model to iterate over a list of matching paths.

### `rig/no-invalid-agent-fields`

Unknown or misspelled fields on the `agent()` spec object are silently ignored at runtime. TypeScript does not always catch them due to excess property checking gaps, so this rule flags them explicitly.

Valid fields: `name`, `instructions`, `input`, `output`, `model`, `maxTurns`, `addons`, `agents`, `systemMessage`, `tools`.

```ts
// Invalid — "instructions2" is silently dropped at runtime
agent({ instructions2: p`Review the diff.` })

// Valid
agent({ instructions: p`Review the diff.` })
```

No autofix (the correct field name must be supplied by the author).

### `rig/enum-return-needs-as-const`

A `defineTool` handler that returns a bare string literal widens the return type to `string`, which breaks the enum schema comparison. TypeScript reports the error at the `output` schema, not at the `return` site.

```ts
// Invalid — TypeScript widens "stable" to string
handler: ({ risk }) => {
return "stable";
}

// Valid — literal type preserved
handler: ({ risk }) => {
return "stable" as const;
}
```

The rule autofixes by inserting `as const` after the string literal.

## Adding rules

Put rule implementations in `skills/rig/eslint/rules/`, export them from `skills/rig/eslint/index.js`, add the equivalent skill-local check to `skills/rig/eslint/lint.js`, and cover both in `src/eslint-rules.test.js`.
Loading