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
1 change: 0 additions & 1 deletion libs/tasks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import './formats.js';
export * from './async-validation.js';
export * from './context.js';
export * from './rubric.js';
export * from './rubrics/pr-complexity.js';
export * from './success-criteria.js';
export * from './task-type-registry.js';
export * from './task-types/index.js';
Expand Down
91 changes: 0 additions & 91 deletions libs/tasks/src/rubrics/pr-complexity.ts

This file was deleted.

38 changes: 38 additions & 0 deletions rubrics/pr-complexity-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"criteria": [
{
"description": "How much does a reviewer have to hold in their head to assess this change? Score 1.0 for a single-purpose, well-named change with clear before/after semantics. Score 0.0 for a sprawling diff that interleaves refactor, behavior change, and dependency bumps. Look at: number of distinct concepts touched, naming clarity, whether the PR description explains the why.",
"id": "cognitive_load",
"scoring": "llm_score",
"weight": 0.25
},
{
"description": "How far can a regression from this change propagate? Score 1.0 if the change is isolated to a single module with no public API surface change. Score 0.0 if it modifies a public contract, schema, migration, auth path, or shared utility consumed across the codebase. Look at: package boundaries crossed, exported symbols changed, schema/migration files touched, configuration defaults altered.",
"id": "blast_radius",
"scoring": "llm_score",
"weight": 0.25
},
{
"description": "Does the change add or update tests proportional to the risk it introduces? Score 1.0 when new behavior is covered by tests at the right level (unit/integration/e2e) AND existing tests still pass without modification. Score 0.0 when behavior changes are landed without tests, or when tests are deleted/disabled to make the change pass. Look at: ratio of test changes to source changes, whether new branches have at least one test, whether any test file lost coverage.",
"id": "test_coverage_delta",
"scoring": "llm_score",
"weight": 0.2
},
{
"description": "Does the change touch authentication, authorization, cryptography, secrets, or external network egress? Score 1.0 when the PR is purely internal refactor or pure UI/copy with no security implications. Score 0.0 when it modifies an auth path, changes a permission check, introduces a new external dependency that processes user input, or alters a crypto primitive. Score 0.5 when it touches adjacent code (e.g. logs near a token) without changing the security path itself. Always favor a lower score when in doubt.",
"id": "security_surface",
"scoring": "llm_score",
"weight": 0.2
},
{
"description": "Does the PR description, commit messages, and diary entries (if any) tell a reviewer what changed and why? Score 1.0 for a PR with a clear summary, motivated by a linked issue, and individual commit messages that narrate the change. Score 0.0 for an empty description, single bulk commit titled \"wip\" or \"fix\", and no rationale anywhere. Look at: PR title quality, body length and substance, commit message hygiene, presence of \"why\" rather than just \"what.\"",
"id": "reviewer_orientation",
"scoring": "llm_score",
"weight": 0.1
}
],
"preamble": "You are reviewing a GitHub pull request for **complexity** — how hard this change is to review safely, NOT whether it's correct or whether the feature is worthwhile. The diff has already been opened by the producer; your job is to score reviewability.\n\nYou may run `gh pr diff <number>`, `gh pr view <number>`, and read files in the workspace. Don't run tests, don't push commits, don't modify anything. The PR's GitHub URL is in the target metadata.\n\nWhen in doubt about a criterion, score conservatively (lower) and explain what made the call ambiguous. Reviewers will read your rationale; \"looks fine\" is not useful, \"the change touches three unrelated subsystems and the test coverage on the auth path is unchanged\" is.",
"rubricId": "pr-complexity-v1",
"scope": "pull_request",
"version": "v1"
}
44 changes: 44 additions & 0 deletions rubrics/pr-merge-gates.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"criteria": [
{
"description": "PASS if the PR's total changed lines (added + removed, across all non-generated files) is ≤ 300. FAIL if the count exceeds 300. EXCLUDE the following from the count: lockfiles (`pnpm-lock.yaml`, `go.sum`, `go.work.sum`, `package-lock.json`), generated code (`*.gen.ts`, `*.gen.go`, files under any path containing `/generated/` or `/__generated__/`), embedded schema artifacts (`*.schema.json`, `schemas/*.json`), and OpenAPI specs (`openapi.json`). Start from `gh pr diff <num> | wc -l` and subtract the lines belonging to excluded paths. Cite both the raw count and the excluded subtotal in your evidence.",
"id": "gate_line_count",
"scoring": "boolean",
"weight": 0.1666
},
{
"description": "PASS if the diff does not touch HTTP API contracts or their generated clients. FAIL if any of these are modified: `apps/rest-api/public/openapi.json` (the served public spec), `libs/moltnet-api-client/openapi-normalized.json` (the ogen input), any `libs/moltnet-api-client/oas_*.go` (ogen-generated client surface), `apps/rest-api/scripts/generate-openapi.ts`, or `libs/moltnet-api-client/generate.go`. These define the wire contract MoltNet exposes; a change here means external clients (Go CLI, agent-daemon, third parties) see a different API shape. Wire-protocol changes need human review of compatibility, versioning, and downstream regeneration. Use `gh pr diff <num> --name-only` and check for any path matching the patterns above.",
"id": "gate_no_api_specs",
"scoring": "boolean",
"weight": 0.1666
},
{
"description": "PASS if the diff does not touch deployment, container, or CI surface. FAIL if any of these paths are modified: `infra/`, `docker-compose*.yaml`, `Dockerfile*`, `.github/workflows/`, `.github/actions/`, `fly.toml`, `.fly/`, `nx.json`, or release-please configuration (`release-please-config.json`, `.release-please-manifest.json`). These changes affect what runs in production, how releases are cut, or how CI orchestrates the build — they need a human reviewer who understands the deployment topology.",
"id": "gate_no_infra",
"scoring": "boolean",
"weight": 0.1666
},
{
"description": "PASS if the diff does not touch authentication, authorization, or cryptographic code. FAIL if any of these paths are modified: `libs/auth/`, `libs/crypto-service/`, `apps/rest-api/src/plugins/auth*`, `apps/mcp-server/src/auth*`, or any file path containing `auth`, `crypto`, `signing`, `jwt`, `token`, `ory`, or `keto` (case-insensitive). Security primitives need cryptographer-aware review; the gate is intentionally over-broad — false positives are cheap, false negatives are catastrophic.",
"id": "gate_no_auth",
"scoring": "boolean",
"weight": 0.1666
},
{
"description": "PASS if the diff does not modify the diary-write or accountable-commit audit pipeline. FAIL if any of these are touched: `libs/diary-service/`, `apps/rest-api/src/routes/diary-entries*`, `apps/rest-api/src/routes/signing*`, the `moltnet_create_entry` custom tool in `libs/pi-extension/src/moltnet/tools.ts`, or the diary-entry signing flow in `libs/crypto-service/`. The audit trail is how MoltNet proves what an agent did; changes here must be reviewed.",
"id": "gate_no_audit_logging",
"scoring": "boolean",
"weight": 0.1666
},
{
"description": "PASS if the diff does not modify the rules that govern how every future agent task runs. FAIL if any of these are touched: `AGENTS.md` and its `CLAUDE.md` symlink at the repo root, any nested `AGENTS.md` / `CLAUDE.md` in subdirectories, `libs/pi-extension/src/runtime/runtime-instructor.ts`, `.claude/rules/`, `.claude/skills/legreffier/`, or `.agents/skills/legreffier/`. These define the operating-context every agent inherits — a bad change here propagates silently to all subsequent task runs and corrupts the audit trail's reliability. Must be reviewed by someone who runs agents regularly.",
"id": "gate_no_agent_runtime_rules",
"scoring": "boolean",
"weight": 0.167
}
],
"preamble": "You are evaluating whether a GitHub pull request meets the **objective gates** for low-risk auto-merge in this repository. Each criterion is a deterministic boolean: PASS if the gate is satisfied, FAIL if it is not. There are no fractional scores. Do NOT score reviewability or code quality here — that's the job of a separate rubric. Stick to mechanical inspection of the diff and the file paths it touches.\n\nUse `gh pr diff <number>` and `gh pr view <number>` to inspect the diff. Read individual files in the workspace if a path inspection requires confirmation. Don't run tests, don't push commits, don't modify anything.\n\nFor each gate, report PASS or FAIL with concrete evidence: a file path that triggered FAIL, a line count number, the result of a substring search. Vague answers (\"looks clean\") are not useful. When the diff is ambiguous (e.g. a renamed file might or might not be agent-facing rules), favor FAIL — the gate exists to prevent unsupervised merges through paths that need human eyes.\n\nThe composite score this rubric produces is purely informational; auto-merge consumers should AND-gate on every PASS, not on a weighted sum.\n\n**After** you submit your structured judgment via the submit tool, post a PR comment on the target PR summarizing each gate's PASS/FAIL verdict with one-line evidence. Use `gh pr comment <number>` from the bash tool — the guest VM has `gh` on PATH with a valid `GH_TOKEN` from the agent's GitHub App, so this works without host_exec. Format the comment as a markdown checklist (`- [x]` for PASS, `- [ ]` for FAIL), titled `## Auto-merge gate evaluation (pr-merge-gates-v1)`. End with a single-line composite verdict (`Verdict: <all gates pass | N gate(s) failed>`). Do NOT include the rubric's full criterion descriptions in the comment — link to `rubrics/pr-merge-gates.json` instead.",
"rubricId": "pr-merge-gates-v1",
"scope": "pull_request",
"version": "v1"
}
76 changes: 70 additions & 6 deletions tools/src/tasks/assess-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,20 @@
*
* Usage
* -----
* pnpm --filter @moltnet/tools task:assess-pr --target-task <uuid>
* pnpm --filter @moltnet/tools task:assess-pr --target-task <uuid> --dry-run
* pnpm --filter @moltnet/tools task:assess-pr \
* --target-task <uuid> --rubric rubrics/pr-complexity-v1.json
* pnpm --filter @moltnet/tools task:assess-pr \
* --target-task <uuid> --rubric rubrics/pr-merge-gates.json --dry-run
*
* Flags
* -----
* -t, --target-task UUID of the fulfill_brief task to assess (required)
* -r, --rubric Path to a Rubric JSON file (required). Resolved
* relative to CWD. The @moltnet/tasks library ships
* no default rubric — consumers author or fork one
* per repo. See `rubrics/` at the repo root for
* starter rubrics (pr-complexity-v1.json,
* pr-merge-gates.json).
* -a, --agent MoltNet agent name (default: legreffier)
* --dry-run Print the synthesized Task input + skip the POST.
*
Expand All @@ -50,33 +58,47 @@
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { isAbsolute, join, resolve } from 'node:path';
import { parseArgs, parseEnv } from 'node:util';

import {
ASSESS_BRIEF_TYPE,
type AssessBriefInput,
PR_COMPLEXITY_V1,
Rubric,
} from '@moltnet/tasks';
import { Value } from '@sinclair/typebox/value';
import { connect } from '@themoltnet/sdk';

const { values: args } = parseArgs({
options: {
'target-task': { type: 'string', short: 't' },
agent: { type: 'string', short: 'a', default: 'legreffier' },
rubric: { type: 'string', short: 'r' },
'dry-run': { type: 'boolean', default: false },
},
});

if (!args['target-task']) {
console.error(
'Usage: tsx tools/src/tasks/assess-pr.ts --target-task <uuid> [--agent <name>] [--dry-run]',
'Usage: tsx tools/src/tasks/assess-pr.ts --target-task <uuid> --rubric <path> [--agent <name>] [--dry-run]',
);
process.exit(1);
}

if (!args.rubric) {
console.error(
'Missing required flag: --rubric <path-to-rubric.json>\n\n' +
'Rubrics are repo-specific — there is no default. See `rubrics/` at\n' +
'the repo root for examples (pr-complexity-v1.json, pr-merge-gates.json),\n' +
'or author your own. The file must conform to the @moltnet/tasks Rubric\n' +
'schema (rubricId, version, criteria[]; weights sum to 1).',
);
process.exit(1);
}

const targetTaskId = args['target-task'];
const agentName = args.agent!;
const rubricArg = args.rubric;
const dryRun = args['dry-run']!;

const UUID_RE =
Expand Down Expand Up @@ -119,11 +141,53 @@ async function main() {
);
}

// Resolve --rubric against the repo root, not the caller's CWD, so
// `--rubric rubrics/pr-merge-gates.json` works the same whether the
// operator is at the repo root, in `tools/`, or in a worktree
// subdirectory. Absolute paths are passed through unchanged. The
// lib ships no default rubric — consumers author or fork one per repo.
const rubricPath = isAbsolute(rubricArg)
? rubricArg
: resolve(mainRepo, rubricArg);
let rubricJson: unknown;
try {
rubricJson = JSON.parse(readFileSync(rubricPath, 'utf8'));
} catch (err) {
throw new Error(
`Failed to read --rubric ${rubricPath}: ` +
(err instanceof Error ? err.message : String(err)),
);
}
if (!Value.Check(Rubric, rubricJson)) {
const errors = [...Value.Errors(Rubric, rubricJson)].slice(0, 5);
throw new Error(
`Rubric at ${rubricPath} does not match the @moltnet/tasks Rubric schema. ` +
`First ${errors.length} validation error(s):\n` +
errors.map((e) => ` - ${e.path || '(root)'}: ${e.message}`).join('\n'),
);
}
const rubric = rubricJson;

// Weights must sum to 1 (TypeBox enforces only per-element 0..1
// bounds; the cross-element invariant is policy, not schema). Check
// here so the operator gets a fast error instead of a server-side
// rejection later — and to make the rubric author's intent explicit.
const totalWeight = rubric.criteria.reduce(
(acc: number, c: { weight: number }) => acc + c.weight,
0,
);
if (Math.abs(totalWeight - 1) > 0.005) {
throw new Error(
`Rubric ${rubric.rubricId}@${rubric.version}: criterion weights ` +
`sum to ${totalWeight.toFixed(4)}, expected 1.0 (tolerance ±0.005).`,
);
}

const input: AssessBriefInput = {
targetTaskId,
successCriteria: {
version: 1,
rubric: PR_COMPLEXITY_V1,
rubric,
},
};

Expand Down
Loading