Typed judgement calls for Claude Code: two primitives, judge and rank, under packs that grade issues, pull requests, plans, commits, releases and rules, a…

A Claude Code plugin that makes typed judgement calls where a session would otherwise spend a model turn, or spend context it does not need.
sift asks a backend typed questions about some state and gets back probabilities, never prose. The backend is Jev (TypeSafe's System One model) when TYPESAFE_API_KEY is set, and a small model through the engine's client (haiku by default) otherwise. On that one call sit packs that grade issues, pull requests, plans, commits, releases and rules, a repository watch, tool output pruning and an outbound text gate. Every module can be switched off, logs what it decided, and falls back to the engine's normal behaviour when the judge is unavailable.
Function hooks are early access and must be switched on:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export TYPESAFE_API_KEY=... # optional, the model backend is used without it
claude plugin marketplace add octalide/sift
claude plugin install sift@sift
Or clone it and load it directly:
git clone https://github.com/octalide/sift
claude --plugin-dir ./sift
Options are set in /config under the plugin. Options lists them all and shows how to set them in settings.json.
/sift # backend, modules, watch and decision counts
grade(pack: "issue", subject: "17") # is issue 17 ready to work on
grade(pack: "pr", subject: "dev..HEAD") # the PR this branch would open, before it exists
The model calls these as mcp__sift__grade and the other tools below. To have repository events delivered as prompts, turn on the watch option or subscribe at runtime with the watch tool.
| pack | answers |
|---|---|
issue | is the issue well formed, typed, scoped to this repo, implementable and ready |
pr | is the PR linked, targeted, named, templated, committed and checked as the repo requires (mechanical) |
plan | does a plan cover its issue, add nothing and decide nothing the issue leaves open |
commit | do the commit messages follow the repo's format (mechanical) |
rules | does an issue or text comply with the repo's rule documents |
release | are the commits since the last tag safe to ship, and do the bump and changelog agree |
triage | does a watch event need acting on now |
locate | which files to read or change for an issue or text |
A repository can override these or add its own under .sift/packs/. See Packs.
| tool | does |
|---|---|
grade | runs a pack on a subject and returns its report |
judge | asks typed questions about one state |
rank | asks the same questions of every item in a list |
watch | adds, removes and lists repository subscriptions |
post | writes an issue, PR, comment, review, merge or release after judging its text against the target repo's rules |
prune | turns output pruning off or on for the calling loop |
status | backend, modules, watch and decision counts |
The /sift command offers the same controls. See Tools and options.
post, forge writes from the shell and other outgoing text to the repo's rules: off judges nothing, advise judges and attaches the verdict, enforce refuses a broken rule and points shell writes at post..sift/config.json, commit, branch and release conventions, rule documentspost tool, classify/sift command and every optionshadowJev is in early access, with a waitlist at typesafe.ai. Without a key the model backend works, but it is slower, costs model tokens, and its probabilities are stated rather than calibrated.
MIT licensed, see LICENSE.
hooks/sift.ts 890 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code';
2
3import { POST_TOOL, postKind } from '../src/gate/channels.ts';
4import { enact, gateCall, gateText, OUTBOUND_MODES, verdictLater, verdictOf, type Gated, type Later, type OutboundMode } from '../src/gate/outbound.ts';
5import { HeldPosts } from '../src/gate/held.ts';
6import { METHODS, pendingNote, postCall, postLabel, postOf, rawWriteOf, VERDICTS, type PostInput } from '../src/gate/post.ts';
7import { fallbackNote, gateShellWrite } from '../src/gate/shell.ts';
8import { Verdicts } from '../src/gate/verdicts.ts';
9import { ghWriteOf, GitHubForge } from '../src/forge/github.ts';
10import { grade, ruleSource, scopeOf, type GradeHost, type GradeOptions } from '../src/grade.ts';
11import { Checkouts, type Checkout } from '../src/repo/checkout.ts';
12import { configLayers, globalConfigPath, readConfig } from '../src/repo/config.ts';
13import { digestOf, judgeLine, JUDGE_DEFAULTS, LoggedJudge, makeJudge, resolveApiKey, type ApiKey, type Backend, type Decision } from '../src/judge/index.ts';
14import { rank, type RankItem, type RankOptions, type RankResult } from '../src/judge/rank.ts';
15import { failureText, type Answer, type Judgement, type KeyOrigin, type Questions } from '../src/judge/types.ts';
16import { DecisionLog, type Cost } from '../src/log.ts';
17import { formatReport } from '../src/packs/run.ts';
18import type { Report } from '../src/packs/types.ts';
19import { pruneCall } from '../src/prune/call.ts';
20import { PRUNE_TOOL, PruneLoops } from '../src/prune/loops.ts';
21import { PRUNE_DEFAULTS } from '../src/prune/prune.ts';
22import { Discoveries, DISCOVERY_WAIT_MS, forgeSource } from '../src/rules/discover.ts';
23import { Watches } from '../src/watch/registry.ts';
24import { CI_FILTERS, commandInputOf, formatSubscription, subscriptionOf, type CiFilter, type Filter, type SubscribeInput } from '../src/watch/subscription.ts';
25import { GRACE_MS, Mailbox, ownerNotice, refusalOf } from '../src/watch/mailbox.ts';
26import { SEEN_EVERY_MS, sessionKeys, StoreKeys } from '../src/keys.ts';
27import { Spawns } from '../src/spawns.ts';
28import { Tenure, tenureToken } from '../src/tenure.ts';
29import type { SiftAnswer, SiftGradeOptions, SiftJudgement, SiftQuestion, SiftRankOptions, SiftRankResult, SiftReport } from '../types/sift.d.ts';
30
31type Options = {
32 backend: Backend;
33 apiKey?: string;
34 jevModel: string;
35 jevBaseUrl: string;
36 fallbackModel: string;
37 shadow: boolean;
38 prune: boolean;
39 pruneFloorTokens: number;
40 pruneChunkLines: number;
41 pruneKeepThreshold: number;
42 pruneTools: string;
43 watch: boolean;
44 watchRepos: string;
45 watchMinInterval: number;
46 watchMaxInterval: number;
47 watchDelivery: 'prompt' | 'log';
48 watchIgnoreSelf: boolean;
49 watchIgnoreBots: boolean;
50 watchCi: CiFilter;
51 watchTriage: boolean;
52 watchDeferMaxAgeHours: number;
53 watchStallHours: number;
54 grade: boolean;
55 outbound: OutboundMode;
56 classify: boolean;
57 // conventions under every repo's .sift/config.json: a path or inline json, replacing the global file
58 config: string;
59};
60
61const DEFAULTS: Options = {
62 backend: 'auto',
63 jevModel: JUDGE_DEFAULTS.jevModel,
64 jevBaseUrl: JUDGE_DEFAULTS.jevBaseUrl,
65 fallbackModel: JUDGE_DEFAULTS.fallbackModel,
66 shadow: false,
67 prune: true,
68 pruneFloorTokens: PRUNE_DEFAULTS.floorTokens,
69 pruneChunkLines: PRUNE_DEFAULTS.chunkLines,
70 pruneKeepThreshold: PRUNE_DEFAULTS.keepThreshold,
71 pruneTools: 'Bash,Read',
72 watch: false,
73 watchRepos: '',
74 watchMinInterval: 60,
75 watchMaxInterval: 300,
76 watchDelivery: 'prompt',
77 watchIgnoreSelf: true,
78 watchIgnoreBots: true,
79 watchCi: 'failures',
80 watchTriage: true,
81 watchDeferMaxAgeHours: 24,
82 watchStallHours: 1,
83 grade: true,
84 outbound: 'advise',
85 classify: false,
86 config: '',
87};
88
89export function resolveOptions(raw: PluginOptions): Options {
90 const out: Record<string, unknown> = { ...DEFAULTS };
91 for (const [key, fallback] of Object.entries(DEFAULTS)) {
92 const v = raw[key];
93 if (v === undefined) continue;
94 if (typeof fallback === 'number' && typeof v === 'number' && Number.isFinite(v)) out[key] = v;
95 else if (typeof fallback === 'boolean' && typeof v === 'boolean') out[key] = v;
96 else if (typeof fallback === 'string' && typeof v === 'string') out[key] = v;
97 }
98 if (typeof raw['apiKey'] === 'string' && raw['apiKey'].length > 0) out['apiKey'] = raw['apiKey'];
99 if (!(OUTBOUND_MODES as readonly unknown[]).includes(out['outbound'])) out['outbound'] = DEFAULTS.outbound;
100 return out as Options;
101}
102
103// everything the hooks share once the session is bound: what a grade reads through, and the session's own state
104type Runtime = GradeHost & {
105 // where the jev key came from, for the status line; never the key
106 apiKeyOrigin?: KeyOrigin;
107 log: DecisionLog;
108 // the session's checkout, resolved per call from the session's main working tree
109 session: () => Promise<Checkout>;
110 // the session's watch subscriptions and their pollers; absent without the triage pack
111 watches?: Watches;
112 // the channels a delivery reaches its recipient by: the watch's, and what follows an outbound call once its rules are known
113 mailbox: Mailbox;
114 // the posts held while their rules are found, kept so a reload hands them on
115 held: HeldPosts;
116 sessionId: string;
117 // the lifetime of the session's watch keys and every rules cache
118 storeKeys: StoreKeys;
119 // whether this environment still owns the session's background work, or a reload replaced it
120 tenure: Tenure;
121 // the outbound gate's kept verdicts, every session's
122 verdicts: Verdicts;
123};
124
125// the config option is inline json or a path, relative to the repo root
126async function optionConfig($: EngineInterface, value: string, root: string): Promise<unknown> {
127 const v = value.trim();
128 if (!v) return undefined;
129 if (v.startsWith('{')) return readConfig(v, 'option');
130 const path = v.startsWith('/') ? v : `${root}/${v}`;
131 if (!(await $.fs.exists(path))) throw new Error(`sift config ${path} not found`);
132 return readConfig(await $.fs.read(path), path);
133}
134
135// the global config file, undefined when there is no such file
136async function globalConfig($: EngineInterface, path: string | undefined): Promise<unknown> {
137 if (!path || !(await $.fs.exists(path))) return undefined;
138 return readConfig(await $.fs.read(path), path);
139}
140
141async function apiKeyOf($: EngineInterface, options: Options): Promise<ApiKey | undefined> {
142 return resolveApiKey({
143 option: options.apiKey,
144 env: () => $.env.get('TYPESAFE_API_KEY'),
145 settings: () => $.settings.read(),
146 configDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
147 });
148}
149
150const WATCH_ACTIONS = ['status', 'list', 'start', 'subscribe', 'unsubscribe', 'poll', 'pause', 'resume', 'reset', 'deferred'] as const;
151
152type WatchInput = SubscribeInput & { action?: string; id?: string };
153
154export const register: Register = (on, rawOptions) => {
155 const options = resolveOptions(rawOptions);
156 let runtime: Runtime | undefined;
157 const defaultFilter = (): Filter => ({ items: true, ci: options.watchCi, stall: true });
158
159 const record = (module: string, action: string, extra: Partial<Decision> = {}) => {
160 runtime?.log.push({
161 at: Date.now(),
162 module,
163 backend: runtime.judge.name,
164 ok: true,
165 digest: '',
166 action,
167 shadow: options.shadow,
168 ...extra,
169 });
170 };
171
172 // what an outbound call whose rules were still being found came to once they are known: recorded, and told to the
173 // caller that made it through the mailbox. an instance a reload replaced only logs it, since its deliveries stand down,
174 // and a held post it handed over is told by the environment that took it
175 const follow = (log: (text: string) => void, agentId: string | undefined, later: Promise<Later>, digest: string) =>
176 void later.then(async (l) => {
177 record('outbound', l.action, { digest: `${digest}: ${l.decision?.reason ?? l.text}` });
178 const rt = runtime;
179 if (!l.handedOver && rt && (await rt.tenure.holds())) await rt.mailbox.deliver({ to: agentId, text: l.text });
180 else log(l.text);
181 });
182
183 // where what follows a call reaches the caller that made it
184 const arrival = (agentId: string | undefined) =>
185 agentId
186 ? `It arrives with the result of your next tool call, or as a message that resumes you if you make none within ${GRACE_MS / 1000} s or have ended your turn.`
187 : 'It arrives as a prompt in this session.';
188
189 const ready = (): Runtime => {
190 if (!runtime) throw new Error(UNBOUND);
191 return runtime;
192 };
193
194 // where each subagent was spawned, its grades defaulting there, and which of sift's tools it was given
195 const spawns = new Spawns();
196
197 // sift's tools this environment has registered so far, as the model names them
198 const offered: string[] = [];
199
200 // each loop's task, what prune already dropped for it, and whether it is turned off there
201 const pruneLoops = new PruneLoops();
202
203 // file access via the engine, bound at session start
204 let readFile: (p: string) => Promise<string> = async () => '';
205
206 async function gradeIn(rt: Runtime, packName: string, ref: string, opts: GradeOptions = {}, agentId?: string): Promise<Report> {
207 const { report, subject } = await grade(rt, await scopeOf(rt.checkouts, rt.session, opts.cwd, spawns.of(agentId)), packName, ref, opts);
208 record('grade', report.verdict, { digest: `${packName} ${subject.ref}` });
209 return report;
210 }
211
212 // $.sift as other plugins see it is declared in types/sift.d.ts: each declared type is held to the one it declares
213 type Same<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
214 type Holds<T extends true> = T;
215 type _Contract = [
216 Holds<Same<SiftQuestion, Questions[string]>>,
217 Holds<Same<SiftAnswer, Answer>>,
218 Holds<Same<SiftJudgement, Judgement>>,
219 Holds<Same<SiftRankOptions, RankOptions>>,
220 Holds<Same<SiftRankResult<RankItem>, RankResult<RankItem>>>,
221 Holds<Same<SiftGradeOptions, GradeOptions>>,
222 Holds<Same<SiftReport, Report>>,
223 ];
224
225 on('engine.create', async (_$, e, next) => {
226 const built = await next(e);
227 const sift = {
228 judge: (state: unknown, questions: Questions) => ready().judge.ask(state, questions),
229 rank: <T extends RankItem>(items: T[], questions: Questions, opts: RankOptions) => rank(items, questions, ready().judge, opts),
230 grade: (pack: string, subject: string, opts?: GradeOptions) => gradeIn(ready(), pack, subject, opts),
231 backend: () => (runtime ? runtime.judge.name : 'unbound'),
232 };
233 return { ...built, sift };
234 });
235
236 on('session.start', async ($, e, next) => {
237 readFile = (p) => $.fs.read(p);
238 const fs = { read: (p: string) => $.fs.read(p), exists: (p: string) => $.fs.exists(p), list: (p: string) => $.fs.list(p), stat: (p: string) => $.fs.stat(p) };
239 const store = { get: (k: string) => $.store.get(k), set: (k: string, v: unknown) => $.store.set(k, v), delete: (k: string) => $.store.delete(k), keys: () => $.store.keys() };
240 const sessionId = await $.session.id();
241 const keys = sessionKeys(sessionId);
242 // the environment a reload replaced lives on until its last dispatch settles: it stands down, so its timers poll
243 // and deliver nothing and its writes never clobber what this one loads
244 let watches: Watches | undefined;
245 let mailbox: Mailbox | undefined;
246 const tenure = new Tenure({
247 store,
248 key: keys.tenure,
249 token: tenureToken(Date.now()),
250 after: (ms, fn) => $.clock.after(ms, fn),
251 lost: () => {
252 void watches?.end();
253 void mailbox?.stop();
254 $.ui.log('sift: a reload replaced this instance, its watch and deliveries stand down');
255 },
256 });
257 await tenure.claim();
258 // what the session's background work writes
259 const owned = tenure.store(store);
260 await spawns.bind(owned, keys.agents);
261 const storeKeys = new StoreKeys({ store, now: () => Date.now(), log: (text) => $.ui.log(text) });
262 await storeKeys.touch(sessionId);
263 await storeKeys.sweep(sessionId);
264 $.clock.every(SEEN_EVERY_MS, () => void tenure.holds().then((held) => (held ? storeKeys.touch(sessionId) : undefined)));
265 const log = new DecisionLog(store, sessionId);
266 const apiKey = await apiKeyOf($, options);
267 const inner = makeJudge(
268 { backend: options.backend, apiKey: apiKey?.key, keyOrigin: apiKey?.origin, jevModel: options.jevModel, jevBaseUrl: options.jevBaseUrl, fallbackModel: options.fallbackModel },
269 {
270 fetch: (url, init) => $.http.fetch(url, init),
271 complete: (request) => $.model.complete(request),
272 },
273 );
274 const judge = new LoggedJudge(inner, (d) => log.push({ ...d, module: 'judge', action: 'ask', shadow: false }));
275 // the main working tree, never a worktree the session started in and may later remove
276 const spawnCwd = async () => (await $.session.repo())?.root ?? (await $.session.cwd());
277 const run = (argv: readonly string[], init?: Parameters<typeof $.process.run>[1]) => $.process.run(argv, init);
278 const forge = new GitHubForge(run, spawnCwd);
279 const globalPath = globalConfigPath({ XDG_CONFIG_HOME: await $.env.get('XDG_CONFIG_HOME'), HOME: await $.env.get('HOME') });
280 // the layer under every repository's own conventions, read once
281 const [base] = configLayers({ option: await optionConfig($, options.config, await spawnCwd()), global: await globalConfig($, globalPath) });
282 const checkouts = new Checkouts({ run, fs, forgeAt: (dir) => new GitHubForge(run, async () => dir), base: () => base });
283 const session = async () => checkouts.resolve(await spawnCwd());
284 const bound = await session();
285 // the watch polls under the session's conventions and packs, as bound at start
286 const { config, packs } = bound;
287 const triagePack = packs['triage'];
288 const letters = new Mailbox({
289 store: owned,
290 key: keys.mail,
291 agents: () => $.agent.list(),
292 now: () => Date.now(),
293 submit: async (text) => void (await $.prompt.submit({ text })),
294 send: async (to, text) => refusalOf(await $.tool.call({ tool: 'SendMessage', to, message: text, summary: 'sift watch delivery' })),
295 retire: async (agentId, why) => watches?.retireOwner(agentId, why),
296 schedule: (ms, fn) => tenure.after(ms, fn),
297 log: (text) => $.ui.log(text),
298 });
299 mailbox = letters;
300 watches = triagePack
301 ? new Watches({
302 store: owned,
303 key: keys.subs,
304 stateKey: keys.state,
305 now: () => Date.now(),
306 log: (text) => $.ui.log(text),
307 status: (text) => $.ui.status(text),
308 watcher: {
309 forge,
310 store: owned,
311 judge,
312 pack: triagePack,
313 issuePack: packs['issue'],
314 config,
315 now: () => Date.now(),
316 // each recipient's delivery goes by its channel: the main loop's prompt, or the owning agent's mailbox
317 deliver: async (d) => {
318 if (!(await tenure.holds())) return;
319 if (options.watchDelivery === 'log') {
320 for (const line of [...(d.to ? [`sift watch to ${d.to}:`] : []), ...d.text.split('\n')]) $.ui.log(line);
321 return;
322 }
323 await letters.deliver(d);
324 },
325 log: (text) => $.ui.log(text),
326 schedule: (ms, fn) => tenure.after(ms, fn),
327 onDecision: (event, action, label) => log.push({ at: Date.now(), module: 'watch', backend: judge.name, ok: true, digest: `${event.id} ${event.changes.join(',')}`, action, shadow: options.shadow, answers: { label } }),
328 },
329 options: {
330 minIntervalMs: options.watchMinInterval * 1000,
331 maxIntervalMs: options.watchMaxInterval * 1000,
332 deferMaxAgeMs: options.watchDeferMaxAgeHours * 3600 * 1000,
333 stallMs: options.watchStallHours * 3600 * 1000,
334 seedWindowMs: 90 * 24 * 3600 * 1000,
335 rateFloor: 500,
336 shadow: options.shadow,
337 rules: {
338 ignoreSelf: options.watchIgnoreSelf,
339 ignoreBots: options.watchIgnoreBots,
340 triage: options.watchTriage && options.backend !== 'off',
341 protectedBranches: config.branches.protected,
342 branchPattern: config.branches.pattern,
343 },
344 },
345 })
346 : undefined;
347 const discoveries = new Discoveries({ judge, store, now: () => Date.now(), log: (text) => $.ui.log(text), schedule: (ms, fn) => $.clock.after(ms, fn), waitMs: DISCOVERY_WAIT_MS });
348 runtime = { judge, apiKeyOrigin: apiKey?.origin, log, forge, checkouts, session, fs, discoveries, watches, mailbox: letters, held: new HeldPosts({ store: owned, key: keys.held, holds: () => tenure.holds(), now: () => Date.now(), id: () => tenureToken(Date.now()) }), sessionId, storeKeys, tenure, verdicts: new Verdicts(store) };
349 $.ui.log(`sift: judge ${judge.name}, repo ${bound.repo ?? 'none'}, packs ${Object.keys(bound.packs).join(' ')}`);
350
351 // a subagent keeps the tools it was spawned with, so what it was offered is recorded as each is registered
352 const tool = async (spec: Parameters<typeof $.tool.register>[0]) => {
353 await $.tool.register(spec);
354 offered.push(`mcp__sift__${spec.name}`);
355 };
356
357 if (options.grade) {
358 await tool({
359 name: 'grade',
360 description:
361 'Grade a repository subject with a sift pack and get mechanical findings plus calibrated judgements. Packs and what each expects as subject: issue (an issue number as N or #N, or an issue URL, which may name another repo), pr (a PR number as N or #N, a PR URL, or a range like dev..HEAD graded from the checkout before the PR exists; mechanical checks only: link, target, branch, CI, template, commit format, drift), commit (a ref such as a sha, branch or tag, or a range like main..HEAD; the commit format check only), release ("release" for the required bump alone, or a proposed version like v1.4.0; ref: the branch it is cut from, repo: any repo, no checkout needed), rules (an issue number or URL, or free text in text; a PR or commit is refused), locate (an issue number or URL, or free text in text, lists the files of the checkout to read or change for it), plan (an issue number, text: the plan, judges whether the plan covers the issue, adds nothing beyond it, and decides nothing it leaves open), triage (free text in text or the subject, a repository event: does it need the session to act on it now; the watch runs it on each event). top sets how many items every top list shows, over each pack step\'s own setting: locate\'s paths per level, and any repo-defined pack\'s top steps. Never paste a title or body as the subject: it is a reference, the text goes in text. A missing or malformed subject is refused with the expected form named. The grade reads one checkout: cwd when given (pass it from a worktree or another repository), else the directory the calling subagent was spawned in, else the session\'s repository; that checkout\'s .sift/config.json conventions and .sift/packs apply, and repo-defined packs are available by name.',
362 inputSchema: {
363 type: 'object',
364 properties: {
365 pack: { type: 'string', description: 'pack name' },
366 subject: { type: 'string', description: 'what the pack grades: an issue or PR number (N or #N) or URL, a commit ref or range, "release" or a version. See the pack list for what each accepts' },
367 repo: { type: 'string', description: 'owner/name, defaults to the repository of the checkout the grade reads. An issue, PR by number or plan grade for another repo reads it from the code host under that repo\'s .sift/config.json; so does a release or rules grade when cwd is not given. A commit, PR range or locate grade refuses a repo that is not its checkout\'s' },
368 cwd: { type: 'string', description: 'absolute path of the directory whose checkout the grade reads: its HEAD, working tree, .sift/config.json and .sift/packs, and its repository. Defaults to the directory the calling subagent was spawned in, else the session\'s repository. Pass it when working in a worktree or another repository' },
369 text: { type: 'string', description: 'free text subject for the rules and locate packs, or the plan for the plan pack' },
370 ref: { type: 'string', description: 'release pack: the branch or sha the release is cut from. Defaults to HEAD in a checkout, else the configured PR target branch, else the default branch' },
371 top: { type: 'number', description: 'how many items every top list shows, over each pack step\'s own setting: locate\'s paths per level (default 20) and the top steps of any repo-defined pack' },
372 },
373 required: ['pack', 'subject'],
374 },
375 });
376 await tool({
377 name: 'judge',
378 description:
379 'Ask calibrated typed questions about any state without generating text. questions is an object of id -> {type: "noul"|"choice"|"score", instructions, criteria}. noul answers a probability, choice picks one key of criteria (an object of key -> description), score picks a position in criteria (an ordered array of level descriptions). Use it for classification, routing, and yes/no checks where a probability is more useful than prose.',
380 inputSchema: {
381 type: 'object',
382 properties: {
383 state: { description: 'what the questions are about: an object or a string' },
384 questions: { type: 'object', description: 'id -> question' },
385 },
386 required: ['state', 'questions'],
387 },
388 });
389 await tool({
390 name: 'rank',
391 description:
392 'Score many items with the same typed questions and get them back in input order with their answers, plus a view sorted by one question. questions has the judge shape; {k} in a question stands for the item index and {field} for a field of an object item ({text} for a string item). mode "batched" fills each request with as many items as fit, so items can see each other and it is cheapest; "isolated" sends one request per item so no item colours another. Use it for "which of these N" problems: relevance, triage, dedup, picking a best candidate.',
393 inputSchema: {
394 type: 'object',
395 properties: {
396 items: { type: 'array', description: 'the items to score: strings or objects' },
397 questions: { type: 'object', description: 'id -> question, asked of every item' },
398 mode: { type: 'string', enum: ['batched', 'isolated'], description: 'batched (default) or isolated' },
399 context: { type: 'object', description: 'state every item is read against, placed beside the items' },
400 by: { type: 'string', description: 'the question the sorted view orders by, the first when absent' },
401 choice: { type: 'string', description: 'for a choice question in by: the key whose probability orders the view' },
402 fields: { type: 'array', items: { type: 'string' }, description: 'the item fields the state carries beside k, every field when absent; the others only fill the questions' },
403 },
404 required: ['items', 'questions'],
405 },
406 });
407 }
408
409 await tool({
410 name: 'status',
411 description: 'sift status: judge backend, enabled modules, the watch subscriptions and their pollers, and decision counts. Call it at session start to learn whether repository events will be delivered to you as prompts.',
412 inputSchema: { type: 'object', properties: {} },
413 });
414 await tool({
415 name: 'watch',
416 description:
417 'Control the sift watch, a set of subscriptions polled one repository at a time. subscribe (repo, default the one checked out where the calling subagent was spawned, else the session\'s; scope: repo, pr <n>, branch <name>, run <id> or tag <glob>; items, ci, stall filter what reaches you; until: settled, merged, closed or an iso time; returns the id), unsubscribe (id), list (every subscription with its scope, filter and owner), start (subscribe to that repository with the configured filter, or to one pull request or branch when for names it, until settled from a subagent), status, poll (one poll now), pause, resume, reset (forget the cursor and reseed), deferred (events held back). poll, pause, resume, reset and deferred act on every polled repository, or on repo alone. A subscription made from a subagent belongs to it and outlives its turn: subscribe, end your turn, and the delivery resumes you. It arrives with your next tool call, or as a message after 60 s without one or once you have ended your turn. Do not wait or poll for it. It is removed by until, unsubscribe, or once no message can reach you.',
418 inputSchema: {
419 type: 'object',
420 properties: {
421 action: { type: 'string', enum: [...WATCH_ACTIONS] },
422 repo: { type: 'string', description: 'owner/name. subscribe and start: the repository, default the one checked out where the calling subagent was spawned, else the session\'s. poll, pause, resume, reset, deferred: only this repository' },
423 scope: { type: 'string', description: 'subscribe: repo (default), pr <n> (the pull request across its heads), branch <name> (its runs), run <id> (that run, read by id until it completes), tag <glob> (runs on tags matching the glob)' },
424 items: { type: 'boolean', description: 'subscribe: deliver issue and pull request events in scope, default true' },
425 ci: { type: 'string', enum: [...CI_FILTERS], description: 'subscribe: settled (each pull request head\'s verdict, and each completed run on a branch, tag or run scope), failures (verdicts and failed runs), all (every completed run as well), none. Default the watchCi option' },
426 stall: { type: 'boolean', description: 'subscribe: deliver a pull request head whose checks stall, default true' },
427 until: { type: 'string', description: 'subscribe: settled (after its first verdict or completed run is delivered), merged or closed (pr scope), or an iso time; the subscription is removed once reached' },
428 id: { type: 'string', description: 'unsubscribe: the subscription id' },
429 for: { type: 'string', description: 'start only: the pull request to follow, its number or head branch, instead of the whole repository. From a subagent it lasts until settled unless until says otherwise' },
430 },
431 },
432 });
433 if (options.prune) {
434 await tool({
435 name: 'prune',
436 description:
437 'Turn sift\'s pruning of long Bash and Read output off or on for the calling loop alone (this subagent, or the main loop). off lasts until the loop\'s next task (a subagent\'s whole run), or for the next calls outputs prune would otherwise judge; on turns it back on. Call off before reading a document in full when every line matters. For one Bash command, end it with # sift: full instead. A Read with offset or limit, a repeat of a Read or command that was pruned, and a Read of a path your task names are never pruned. A Read is only ever cut at its tail, so its line numbers stay true.',
438 inputSchema: {
439 type: 'object',
440 properties: {
441 action: { type: 'string', enum: ['off', 'on'] },
442 calls: { type: 'number', description: 'off only: how many outputs over the floor stay whole, default until the loop\'s next task' },
443 },
444 required: ['action'],
445 },
446 });
447 }
448 await tool({
449 name: 'post',
450 description:
451 `Write an issue, pull request, comment, review, merge or release on ${forge.name} to the repository named in repo, never the one the working directory implies. What happens to its text follows the outbound mode. Under advise, the default, the text is judged against that repository's rule documents and its outbound channels, the write is made whatever the verdict, and the url is returned with the verdict. Under enforce a broken rule or a channel's length limit refuses the write, naming the rule and quoting the lines that break it, and nothing is written, unless override gives the reason it goes through as written. Under off the write is made unjudged. While the rules of the repository are still being found, or the rules the text may break are checked, advise writes at once and the advice follows, and enforce answers held and writes or refuses the post once that is done, the url or the refusal following; what follows arrives with a later tool call or as a message, so never make the same post again. Every kind takes repo and kind. issue-create: title, body. pr-create: title, body, base, head (a branch, or owner:branch from a fork), draft. issue-comment, pr-comment: number, body. issue-edit, pr-edit: number, title or body or both. pr-review: number, verdict, body (required unless approving). pr-merge: number, method, title and body for the merge commit. release-create: tag, body (the notes), title, target, draft, prerelease. release-edit: tag, title or body. Writing these through gh in Bash is refused under enforce; labels, assignees, closing and the like stay with gh.`,
452 inputSchema: {
453 type: 'object',
454 properties: {
455 repo: { type: 'string', description: 'the repository written to, as owner/name' },
456 kind: { type: 'string', enum: forge.writes.map(postKind), description: 'the artifact and what the write does to it' },
457 number: { type: 'number', description: 'the issue or pull request number, for comment, edit, review and merge' },
458 tag: { type: 'string', description: 'release-create and release-edit: the release tag' },
459 title: { type: 'string', description: 'the title; for pr-merge the merge commit subject' },
460 body: { type: 'string', description: 'the body, comment, review, release notes, or for pr-merge the merge commit message' },
461 base: { type: 'string', description: 'pr-create: the branch merged into' },
462 head: { type: 'string', description: 'pr-create: the branch merged from' },
463 draft: { type: 'boolean', description: 'pr-create and release-create: open as a draft' },
464 verdict: { type: 'string', enum: [...VERDICTS], description: 'pr-review: the review verdict' },
465 method: { type: 'string', enum: [...METHODS], description: 'pr-merge: how the pull request is merged' },
466 target: { type: 'string', description: 'release-create: the branch or sha the tag is created from when it does not exist' },
467 prerelease: { type: 'boolean', description: 'release-create: mark the release a prerelease' },
468 override: { type: 'string', description: 'under enforce: the reason this write goes through although the judge refuses it. The decision log records it with the ruling and the text' },
469 },
470 required: ['repo', 'kind'],
471 },
472 });
473 await $.command.register({ name: 'sift', description: 'sift status, log, prune and watch control', argumentHint: '[status|log|clear|prune off [n]|prune on|watch status|list|poll|pause|resume|reset|deferred [repo]|start [repo] [for <pr|branch>]|subscribe <repo> [scope]|unsubscribe <id>]' });
474
475 await letters.load();
476 const watchRepos = options.watchRepos.split(',').map((r) => r.trim()).filter(Boolean);
477 // the rules a post and the gate read are found in the background before the first write needs them: the bound
478 // repository's and each watched one's on the forge, and the bound checkout's own
479 if (options.outbound !== 'off' && options.backend !== 'off' && bound.packs['rules']) {
480 const warm = (scope: string, found: Promise<unknown>) => void found.catch((error: unknown) => $.ui.log(`sift rules ${scope}: discovery at start failed (${messageOf(error)})`));
481 for (const repo of new Set([...(bound.repo ? [bound.repo] : []), ...watchRepos])) warm(repo, checkouts.remoteConfig(forge, repo).then((c) => discoveries.settle(forgeSource(forge, repo), c.rules)));
482 if (bound.git) warm(bound.root, discoveries.settle(ruleSource({ forge, fs }, { checkout: bound, named: false }, bound.repo), bound.config.rules));
483 }
484 // after the discoveries, so a post taken over finds its rules on the way
485 void takeOver(ready(), (text) => $.ui.log(text)).catch((error: unknown) => $.ui.log(`sift post: taking over held posts failed (${messageOf(error)})`));
486 if (watches) {
487 await watches.load();
488 if (options.watch) {
489 const repos = [...watchRepos];
490 if (repos.length === 0 && bound.repo) repos.push(bound.repo);
491 if (repos.length === 0) $.ui.log(`sift watch: no repository to watch (set watchRepos or run in a checkout with a ${forge.name} remote)`);
492 for (const repo of repos) await watches.subscribe({ repo, scope: { kind: 'repo' }, filter: defaultFilter() });
493 }
494 } else if (options.watch) {
495 $.ui.log('sift watch: triage pack missing');
496 }
497 return next(e);
498 });
499
500 // the session's watch keys go with it. clear and resume leave the process running under the id bound at start, so
501 // its keys stay in use
502 on('classic.SessionEnd', async (_$, e, next) => {
503 if (runtime && e.reason !== 'clear' && e.reason !== 'resume') {
504 const rt = runtime;
505 await rt.watches?.end();
506 await rt.mailbox?.stop();
507 await rt.storeKeys.end(rt.sessionId);
508 }
509 return next(e);
510 });
511
512 // registered first, so outermost over every tool. a delivery waiting for a subagent rides the result of its next
513 // tool call, whichever tool and whichever hook answers it; beneath that, outbound gate before, prune after
514 on('tool.call', async ($, e, next) => {
515 const answer = async (): Promise<Awaited<ReturnType<typeof next>>> => {
516 if (e.tool.startsWith('mcp__sift__')) return next(e);
517 const rt = runtime;
518 const command = (e as unknown as { command?: unknown }).command;
519 const mode = options.outbound;
520 const shell = mode !== 'off' && e.tool === 'Bash' && typeof command === 'string' ? command : undefined;
521 // a reload's new environment answers before its session start has bound it: an enforced forge write waits for
522 // the gate, read by the cli of the forge session start binds
523 if (!rt) {
524 const early = mode === 'enforce' && shell !== undefined ? rawWriteOf({ writeOf: ghWriteOf }, shell) : undefined;
525 return early ? { deny: `sift outbound: ${UNBOUND}, so this ${postKind(early)} write from the shell cannot be judged yet. Run the command again in a moment.` } : next(e);
526 }
527 // the checkout a grade with no cwd reads: where this loop was spawned, else the session's
528 const checkoutOf = async () => (await scopeOf(rt.checkouts, rt.session, undefined, spawns.of(e.agentId))).checkout;
529 // verdicts an advised call carries back beside its result
530 const notes: string[] = [];
531 // a pending decision's verdict, told to this caller under head once the rules are known. sift does not make
532 // this call itself, so it is never held: under advise it runs now, under enforce it is refused now
533 const later = ({ checkout, outbound, decision }: Gated, head: string) => {
534 const again = async () => {
535 const gated = await gateText(rt, checkout, outbound);
536 if (!gated) throw new Error(`the checkout ${checkout.root} has no rules pack`);
537 return gated.decision;
538 };
539 if (mode !== 'off') follow((text) => $.ui.log(text), e.agentId, verdictLater(mode, outbound, decision, again, head), outbound.channel);
540 };
541 // a verdict at once, or when the rules are still being found, a note that it follows once they are known
542 const advised = (gated: Gated) => {
543 const { checkout, outbound, decision } = gated;
544 if (!decision.pending) return void notes.push(verdictOf(outbound, decision));
545 later(gated, `sift outbound advice on the ${outbound.channel} text of this ${e.tool} call, now that ${decision.confirming ? 'it is checked' : 'its rules are known'}`);
546 notes.push(`${pendingNote(outbound, decision, checkout.repo ?? checkout.root)}. ${arrival(e.agentId)}`);
547 };
548 // an enforced refusal; while the rules are still being found the call is refused unjudged, and the verdict it
549 // would meet follows once they are known, for the caller to run it again then. one the first round found may
550 // break a rule is refused now, and the checked verdict, quoting what breaks each rule, follows
551 const refusal = (gated: Gated, then: string) => {
552 const { checkout, outbound, decision } = gated;
553 if (!decision.pending) return `sift outbound (${outbound.channel}): ${decision.reason}. ${then}`;
554 if (decision.confirming) {
555 later(gated, `sift outbound: the checked verdict on the ${outbound.channel} text of the ${e.tool} call refused as it may break a rule. Run that call again, rewritten if it breaks one`);
556 return `sift outbound (${outbound.channel}): this call is refused, as its text ${decision.reason.replace(/^may break: /, 'may break ')}. The checked verdict, quoting the lines that break each rule, follows. ${arrival(e.agentId)} ${then}`;
557 }
558 const scope = checkout.repo ?? checkout.root;
559 later(gated, `sift outbound: the rules of ${scope} are known, and this is the verdict the ${outbound.channel} text of the ${e.tool} call refused while they were found would meet. Run that call again, rewritten if it breaks a rule`);
560 return `sift outbound (${outbound.channel}): the rules of ${scope} are still being found, so this call is refused unjudged. The verdict on its text follows once they are known. ${arrival(e.agentId)} Run the call again then.`;
561 };
562 // a forge write from the shell goes through post under enforce, which names its destination; a loop without
563 // post, and every loop under advise, has its text judged
564 const raw = shell !== undefined ? rawWriteOf(rt.forge, shell) : undefined;
565 if (raw && mode !== 'off') {
566 const gate = await gateShellWrite(rt, raw, spawns.has(e.agentId, POST_TOOL), mode, checkoutOf, e as unknown as Record<string, unknown>, readFile);
567 const without = gate.fallback ? ' without post' : '';
568 if ('refused' in gate) {
569 record('outbound', options.shadow ? 'would-refuse' : 'refuse', { digest: `shell ${postKind(raw)}${without}` });
570 if (options.shadow) $.ui.log(`sift outbound (shadow): would refuse: ${gate.refused}`);
571 else return { deny: `sift outbound: ${gate.refused}.` };
572 } else if (gate.unread !== undefined) {
573 record('outbound', options.shadow ? 'would-advise' : 'advise', { digest: `shell ${postKind(raw)}${without}: text not read` });
574 if (!options.shadow) notes.push(gate.unread);
575 } else if (gate.gated) {
576 const { outbound, decision } = gate.gated;
577 const done = enact(mode, decision, options.shadow);
578 record('outbound', done.action, { digest: `${outbound.channel}${without} ${outbound.text.length} chars: ${decision.reason}` });
579 for (const w of decision.warnings) $.ui.log(`sift outbound (${outbound.channel}): ${w}`);
580 if (done.refuse) return { deny: refusal(gate.gated, `${fallbackNote(rt.forge)}. Rewrite the text or ask the user.`) };
581 if (!decision.allow && options.shadow) $.ui.log(`sift outbound (shadow): would ${mode === 'enforce' ? 'deny' : 'advise on'} ${outbound.channel} text: ${decision.reason}`);
582 if (done.advise) advised(gate.gated);
583 }
584 }
585 const checkout = mode !== 'off' && !raw ? await checkoutOf() : undefined;
586 const gated = checkout ? await gateCall(rt, checkout, e.tool, e as unknown as Record<string, unknown>, readFile) : undefined;
587 if (gated) {
588 const { outbound, decision } = gated;
589 const done = enact(mode, decision, options.shadow);
590 record('outbound', done.action, { digest: `${outbound.channel} ${outbound.text.length} chars: ${decision.reason}` });
591 for (const w of decision.warnings) $.ui.log(`sift outbound (${outbound.channel}): ${w}`);
592 if (done.refuse) return { deny: refusal(gated, 'Rewrite the text or ask the user.') };
593 if (!decision.allow && options.shadow) $.ui.log(`sift outbound (shadow): would ${mode === 'enforce' ? 'deny' : 'advise on'} ${outbound.channel} text: ${decision.reason}`);
594 if (done.advise) advised(gated);
595 }
596 const r = await next(e);
597 const pruned: Awaited<ReturnType<typeof next>> = !options.prune
598 ? r
599 : await pruneCall(
600 { tool: e.tool, input: e as unknown as Record<string, unknown>, agentId: e.agentId },
601 r,
602 pruneLoops,
603 rt.judge,
604 { ...PRUNE_DEFAULTS, floorTokens: options.pruneFloorTokens, chunkLines: options.pruneChunkLines, keepThreshold: options.pruneKeepThreshold, tools: pruneTools(options), shadow: options.shadow },
605 { record: (action, extra) => record('prune', action, extra), toast: (text) => $.ui.toast(text) },
606 );
607 if (notes.length === 0 || pruned.deny !== undefined) return pruned;
608 return { ...pruned, context: [...(pruned.context ?? []), ...notes] };
609 };
610 const r = await answer();
611 const rt = runtime;
612 const mailbox = rt?.mailbox;
613 // an instance a reload replaced while this call ran has only stale letters; the one that replaced it delivers
614 if (!e.agentId || !rt || !mailbox || r.deny !== undefined || !(await rt.tenure.holds())) return r;
615 const letters = await mailbox.take(e.agentId);
616 return letters.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...letters] };
617 });
618
619 on('tool.call', { tool: 'mcp__sift__grade' }, async ($, e) => {
620 const input = e as unknown as { pack: string; subject: string; repo?: string; cwd?: string; text?: string; ref?: string; top?: number };
621 try {
622 const subject = input.subject === undefined || input.subject === null ? '' : String(input.subject);
623 const report = await gradeIn(ready(), input.pack, subject, { repo: input.repo, cwd: input.cwd, text: input.text, ref: input.ref, top: input.top }, e.agentId);
624 return { result: [{ type: 'text', text: formatReport(report) }] };
625 } catch (error) {
626 return { deny: `sift grade failed: ${messageOf(error)}` };
627 }
628 });
629
630 // the destination is the repo the call names; the rules pack is the caller's, as a grade's is
631 // one post made for the caller it answers, logged, what follows it handed to the mailbox; the answer is its text,
632 // and whether it refuses
633 const makePost = async (rt: Runtime, log: (text: string) => void, to: string | undefined, input: PostInput): Promise<{ text: string; refused: boolean }> => {
634 const pack = (await scopeOf(rt.checkouts, rt.session, undefined, spawns.of(to))).checkout.packs['rules'];
635 const hold = async (held: PostInput) => {
636 const id = await rt.held.keep(to, held);
637 return () => rt.held.claim(id);
638 };
639 const posted = await postCall({ forge: rt.forge, judge: rt.judge, discoveries: rt.discoveries, verdicts: rt.verdicts, config: (repo) => rt.checkouts.remoteConfig(rt.forge, repo), hold }, pack, input, options.outbound, options.shadow);
640 const { outbound, decision, action } = posted;
641 if (outbound && decision && action) {
642 const override = posted.override !== undefined ? { override: posted.override, reason: decision.reason, text: outbound.text } : {};
643 record('outbound', action, { digest: `${outbound.channel} ${String(input.repo)} ${outbound.text.length} chars: ${decision.reason}`, ...override });
644 for (const w of decision.warnings) log(`sift outbound (${outbound.channel}): ${w}`);
645 if (!decision.allow && options.shadow) log(`sift outbound (shadow): would ${options.outbound === 'enforce' ? 'deny' : 'advise on'} ${outbound.channel} text: ${decision.reason}`);
646 }
647 if (posted.later) follow(log, to, posted.later, `${outbound?.channel} ${String(input.repo)}`);
648 if ('refused' in posted) return { refused: true, text: `sift post refused: ${posted.refused}. ${decision ? 'Rewrite the text, or post again with override set to the reason it should go through as written.' : 'Fix the input and post again.'}` };
649 if ('held' in posted) return { refused: false, text: `held: ${posted.held} ${arrival(to)} Do not post it again.` };
650 if (posted.verdict !== undefined) return { refused: false, text: `${posted.url}\n${posted.verdict}${posted.later ? `. ${arrival(to)}` : ''}` };
651 return { refused: false, text: posted.url };
652 };
653
654 // the destination is the repo the call names; the rules pack is the caller's, as a grade's is
655 on('tool.call', { tool: POST_TOOL }, async ($, e) => {
656 try {
657 const made = await makePost(ready(), (text) => $.ui.log(text), e.agentId, e as unknown as PostInput);
658 return made.refused ? { deny: made.text } : { result: [{ type: 'text', text: made.text }] };
659 } catch (error) {
660 return { deny: `sift post failed: ${messageOf(error)}` };
661 }
662 });
663
664 // the posts an environment a reload replaced left held, made here and each told to its caller: the post goes out
665 // once whichever environment makes it, and its caller hears how it went
666 const takeOver = async (rt: Runtime, log: (text: string) => void) => {
667 for (const held of await rt.held.takeOver()) {
668 const parsed = postOf(held.input, rt.forge);
669 const label = 'error' in parsed ? String(held.input.kind) : postLabel(parsed.repo, parsed.post, String(held.input.kind));
670 const head = `sift post: a reload replaced the sift environment that held the ${label}, and this one took it over`;
671 const made = await makePost(rt, log, held.to, held.input).catch((error: unknown) => ({ refused: true, text: `sift post failed: ${messageOf(error)}` }));
672 await rt.mailbox.deliver({ to: held.to, text: `${head}. ${made.text}` }).catch((error: unknown) => log(`${head}, and could not tell its caller (${messageOf(error)}): ${made.text}`));
673 }
674 };
675
676 on('agent.spawn', async (_$, e, next) => {
677 const r = await next(e);
678 await spawns.spawned(r.agentId, e.cwd, e.parentAgentId, offered);
679 pruneLoops.spawned(r.agentId, e.prompt);
680 return r;
681 });
682
683 on('tool.call', { tool: 'mcp__sift__judge' }, async ($, e) => {
684 const input = e as unknown as { state: unknown; questions: Questions };
685 const rt = runtime;
686 if (!rt) return { deny: `sift judge: ${UNBOUND}. Call it again in a moment.` };
687 const result = await rt.judge.ask(input.state, input.questions);
688 record('judge-tool', result.ok ? 'answered' : 'failed');
689 const text = result.ok
690 ? Object.entries(result.answers).map(([id, a]) => `${id}: ${answerLabel(a)}`).join('\n')
691 : `judge unavailable: ${failureText(result)}`;
692 return result.ok ? { result: [{ type: 'text', text }] } : { deny: text };
693 });
694
695 on('tool.call', { tool: 'mcp__sift__rank' }, async ($, e) => {
696 const input = e as unknown as { items: RankItem[]; questions: Questions; mode?: RankOptions['mode']; context?: Record<string, unknown>; by?: string; choice?: string; fields?: string[] };
697 const rt = runtime;
698 if (!rt) return { deny: `sift rank: ${UNBOUND}. Call it again in a moment.` };
699 const result = await rank(input.items, input.questions, rt.judge, { mode: input.mode ?? 'batched', context: input.context, by: input.by, choice: input.choice, fields: input.fields });
700 record('rank-tool', result.ok ? 'ranked' : 'failed', { digest: `${input.items.length} items, ${result.requests} requests` });
701 if (!result.ok) return { deny: `rank unavailable: ${failureText(result)}` };
702 const lines = result.sorted.map((r) => `${r.index}: ${r.value.toFixed(3)} ${Object.entries(r.answers).map(([id, a]) => `${id}=${answerLabel(a)}`).join(' ')} ${digestOf(r.item)}`);
703 return { result: [{ type: 'text', text: [`${result.items.length} items in ${result.requests} request${result.requests === 1 ? '' : 's'}, sorted by ${input.by ?? Object.keys(input.questions)[0]}`, ...lines].join('\n') }] };
704 });
705
706 // a person's prompt is the main loop's task for prune, save /sift itself, which controls sift and is no task.
707 // a module that fell back since the last prompt says so once, beside the prompt, instead of hiding in a count
708 on('prompt.submit', async (_$, e, next) => {
709 if (!/^\/sift(\s|$)/.test(e.text.trim())) pruneLoops.submitted(e.origin.kind, e.text);
710 const warnings = runtime?.log.takeWarnings() ?? [];
711 if (warnings.length === 0) return next(e);
712 return next({ ...e, context: [...(e.context ?? []), ...warnings] });
713 });
714
715 on('model.classify', async ($, e, next) => {
716 const rt = runtime;
717 if (!options.classify || !rt) return next(e);
718 const criteria = Object.fromEntries(e.labels.map((l) => [l, l]));
719 const result = await rt.judge.ask({ text: e.text }, { label: { type: 'choice', instructions: 'Which label fits the text best?', criteria } });
720 if (!result.ok || result.answers['label']?.type !== 'choice') {
721 record('classify', 'fallback', { ok: false, reason: result.ok ? 'no choice' : result.message, digest: e.text.slice(0, 60) });
722 return next(e);
723 }
724 const answer = result.answers['label'];
725 record('classify', options.shadow ? 'would-answer' : 'answered', { digest: e.text.slice(0, 60), answers: { label: `${answer.choice}@${answer.confidence.toFixed(2)}` } });
726 if (options.shadow) return next(e);
727 return { value: answer.choice };
728 });
729
730 const k = (n: number) => (n >= 10_000 ? `${Math.round(n / 1000)}k` : String(n));
731
732 async function statusText(rt: Runtime): Promise<string> {
733 const stats = await rt.log.stats();
734 const modules = Object.entries(stats.byModule)
735 .map(
736 ([m, s]) =>
737 ` ${m.padEnd(10)} calls ${String(s.calls).padStart(4)} acted ${String(s.acted).padStart(4)} shadow ${String(s.shadow).padStart(4)} avg ${s.calls ? Math.round(s.latencyMs / s.calls) : 0}ms` +
738 (s.requestTokens || s.responseTokens ? ` in ${k(s.requestTokens)} out ${k(s.responseTokens)}` : '') +
739 (s.tokensRemoved ? ` removed ${k(s.tokensRemoved)}` : '') +
740 (m === 'outbound' ? ` advised ${s.actions['advise'] ?? 0} denied ${(s.actions['deny'] ?? 0) + (s.actions['refuse'] ?? 0)} overridden ${s.actions['override'] ?? 0}` : ''),
741 )
742 .join('\n');
743 const cost = (c: Cost) => `judge in ${k(c.requestTokens)}, out ${k(c.responseTokens)}, context removed ${k(c.tokensRemoved)}`;
744 const enabled = (Object.keys(options) as (keyof Options)[]).filter((k) => typeof options[k] === 'boolean' && options[k]).join(', ');
745 const last = stats.session.lastFailure;
746 const repo = (await rt.session()).repo;
747 const watch = watchSummary(rt);
748 return [
749 `sift: judge ${rt.judge.name}${options.shadow ? ' (shadow mode)' : ''}, repo ${repo ?? 'none'}`,
750 judgeLine(rt.judge.name, rt.apiKeyOrigin, rt.judge.keyRejected),
751 `enabled: ${enabled}; outbound ${options.outbound}`,
752 watch,
753 `this session: ${stats.session.calls} decisions, ${stats.session.failures} failures${last ? ` (last ${last.module} at ${new Date(last.at).toISOString()}: ${last.backend}: ${last.reason ?? 'no reason'})` : ''}`,
754 `all sessions (ring of 500): ${stats.calls} decisions, ${stats.failures} failures`,
755 `cost this session: ${cost(stats.session.cost)}; ring: ${cost(stats.cost)} (tokens, estimated unless the backend reports them)`,
756 modules || ' no decisions yet',
757 ].join('\n');
758 }
759
760 function watchSummary(rt: Runtime): string {
761 const w = rt.watches;
762 if (!w || w.list().length === 0) return 'watch: off';
763 const repos = w.repos().map((repo) => {
764 const st = w.poller(repo)?.snapshot();
765 const state = !st ? 'not polled' : `${st.paused ? 'paused' : 'running'}, ${st.deferred.length} deferred, last poll ${st.lastPoll ? new Date(st.lastPoll).toISOString() : 'never'}`;
766 return ` ${repo}: ${state}`;
767 });
768 const waiting = (rt.mailbox?.pending() ?? []).map((p) => ` waiting for ${p.to}'s next tool call: ${p.count} deliver${p.count === 1 ? 'y' : 'ies'}`);
769 return [`watch: ${w.list().length} subscription${w.list().length === 1 ? '' : 's'} on ${repos.length} repositor${repos.length === 1 ? 'y' : 'ies'}`, ...repos, ...w.list().map((s) => ` ${formatSubscription(s)}`), ...waiting].join('\n');
770 }
771
772 // the watch controls, shared by /sift watch and the watch tool. owner is the agentId of the subagent the call runs
773 // in, if any: what it subscribes belongs to it, on the repository of the checkout it was spawned in unless it names one
774 async function watchControl(rt: Runtime, input: WatchInput, owner?: string): Promise<string> {
775 const w = rt.watches;
776 if (!w) return 'watch unavailable: triage pack missing';
777 const sub = input.action ?? 'status';
778 if (sub === 'list') return w.list().map(formatSubscription).join('\n') || 'no subscriptions';
779 if (sub === 'start' || sub === 'subscribe') {
780 const checkout = await scopeOf(rt.checkouts, rt.session, undefined, spawns.of(owner)).then((s) => s.checkout, (error: unknown) => ({ error: messageOf(error) }));
781 if ('error' in checkout) return `watch cannot subscribe: ${checkout.error}`;
782 const wanted = subscriptionOf(input, { start: sub === 'start', repo: checkout.repo, filter: defaultFilter(), owner });
783 if ('error' in wanted) return `watch cannot subscribe: ${wanted.error}`;
784 const { sub: made, added } = await w.subscribe(wanted);
785 return [`${added ? 'subscribed' : 'already subscribed'}: ${formatSubscription(made)}`, ...(owner ? [ownerNotice(owner)] : [])].join('\n');
786 }
787 if (sub === 'unsubscribe') {
788 const id = input.id?.trim();
789 if (!id) return 'watch unsubscribe needs an id (see watch list)';
790 return (await w.unsubscribe(id)) ? `unsubscribed ${id}` : `no subscription ${id}`;
791 }
792 const repos = input.repo?.trim() ? [input.repo.trim()] : w.repos();
793 const pollers = repos.flatMap((r) => w.poller(r) ?? []);
794 if (pollers.length === 0) return input.repo ? `no subscription on ${input.repo}` : 'watch is off (subscribe, start, or set the watch option to subscribe at boot)';
795 for (const p of pollers) {
796 if (sub === 'pause') await p.pause();
797 else if (sub === 'resume') await p.resume();
798 else if (sub === 'reset') await p.reset();
799 else if (sub === 'poll') await p.tick();
800 }
801 if (sub === 'deferred') {
802 const lines = repos.flatMap((r) => (w.poller(r)?.snapshot().deferred ?? []).map((d) => `${r} ${d.reason.padEnd(24)} ${d.event.kind} ${d.event.number ?? ''} ${d.event.title} ${d.label ?? ''} (${d.subs.join(', ')})`));
803 return lines.join('\n') || 'nothing deferred';
804 }
805 return repos
806 .flatMap((r) => {
807 const st = w.poller(r)?.snapshot();
808 if (!st) return [];
809 return [
810 `watch ${r}: ${st.paused ? 'paused' : 'running'}, ${Object.keys(st.items).length} items, ${Object.keys(st.runs).length} runs cached, ${w.on(r).length} subscriptions`,
811 ` interval ${Math.round(st.interval / 1000)}s, last poll ${st.lastPoll ? new Date(st.lastPoll).toISOString() : 'never'}, failures ${st.failures}`,
812 ` deferred ${st.deferred.length}, self login ${st.login ?? 'unknown'}, cursor ${st.cursor}`,
813 ];
814 })
815 .join('\n');
816 }
817
818 on('tool.call', { tool: 'mcp__sift__watch' }, async ($, e) => {
819 const rt = runtime;
820 const input = e as unknown as WatchInput;
821 const action = String(input.action ?? 'status');
822 if (!rt) return { deny: `sift watch: ${UNBOUND}. Call it again in a moment.` };
823 if (!(WATCH_ACTIONS as readonly string[]).includes(action)) return { deny: `unknown watch action ${action}` };
824 try {
825 return { result: [{ type: 'text', text: await watchControl(rt, { ...input, action }, e.agentId) }] };
826 } catch (error) {
827 return { deny: `sift watch ${action} failed: ${messageOf(error)}` };
828 }
829 });
830
831 on('tool.call', { tool: PRUNE_TOOL }, async ($, e) => {
832 const input = e as unknown as { action?: string; calls?: number };
833 if (input.action !== 'off' && input.action !== 'on') return { deny: `prune takes action off or on, not ${String(input.action)}` };
834 const text = pruneLoops.control(e.agentId, input.action, input.calls);
835 record('prune', `turned-${input.action}`, { digest: `${e.agentId ?? 'main'}: ${text}` });
836 return { result: [{ type: 'text', text }] };
837 });
838
839 on('tool.call', { tool: 'mcp__sift__status' }, async () => {
840 const rt = runtime;
841 return { result: [{ type: 'text', text: rt ? await statusText(rt) : UNBOUND }] };
842 });
843
844 on('command.run', { command: 'sift' }, async ($, e) => {
845 const rt = runtime;
846 if (!rt) return { text: UNBOUND };
847 const [head = 'status', ...rest] = e.args.trim().split(/\s+/).filter(Boolean);
848 if (head === 'log') {
849 const n = Number(rest[0]) || 30;
850 const lines = (await rt.log.recent(n)).map((d) => `${new Date(d.at).toISOString().slice(11, 19)} ${d.module.padEnd(8)} ${d.action.padEnd(12)} ${d.digest}${d.answers ? ' ' + JSON.stringify(d.answers) : ''}${d.reason ? ' ' + d.reason : ''}`);
851 return { text: lines.join('\n') || 'no decisions yet' };
852 }
853 if (head === 'clear') {
854 await rt.log.clear();
855 await rt.verdicts.clear();
856 return { text: 'decision log and kept outbound verdicts cleared' };
857 }
858 if (head === 'prune') {
859 const [action, n] = rest;
860 if (action !== 'off' && action !== 'on') return { text: 'usage: /sift prune off [n] | on' };
861 const text = pruneLoops.control(undefined, action, n === undefined ? undefined : Number(n));
862 record('prune', `turned-${action}`, { digest: `main: ${text}` });
863 return { text };
864 }
865 if (head === 'watch') {
866 const [action = 'status', ...args] = rest;
867 if (!(WATCH_ACTIONS as readonly string[]).includes(action)) return { text: `unknown watch action ${action}` };
868 const input = commandInputOf(action, args);
869 if ('error' in input) return { text: input.error };
870 return { text: await watchControl(rt, input) };
871 }
872 return { text: `${await statusText(rt)}\ncommands: /sift log [n], /sift clear, /sift prune off [n]|on, /sift watch status|list|poll|pause|resume|reset|deferred [repo], /sift watch start [repo] [for <pr|branch>], /sift watch subscribe <repo> [scope], /sift watch unsubscribe <id>` };
873 });
874};
875
876// what a call answered by an environment whose session start has not run yet is told
877const UNBOUND = 'sift is starting or reloading and not bound to the session yet';
878
879function pruneTools(options: Options): string[] {
880 return options.pruneTools.split(',').map((t) => t.trim()).filter(Boolean);
881}
882
883function messageOf(error: unknown): string {
884 return error instanceof Error ? error.message : String(error);
885}
886
887function answerLabel(a: Answer): string {
888 return a.type === 'noul' ? a.p.toFixed(3) : a.type === 'choice' ? `${a.choice} (confidence ${a.confidence.toFixed(2)})` : `${a.legend} (confidence ${a.confidence.toFixed(2)})`;
889}
890src/gate/channels.ts 146 lines1import type { Forge, ForgeArtifact, ForgeWrite } from '../forge/forge.ts';
2import type { TextKind } from '../rules/kinds.ts';
3import { shellWord } from '../shell.ts';
4
5// what a write sets beside its text, by the input field: a value, or a flag that is false when absent
6export type Settings = Record<string, 'value' | 'flag'>;
7
8// where the text is in a call: fields of the tool input, joined in order, on a call whose input holds every value
9// when names, and the fields it sets beside the text; or for a shell command, the command pattern, the flags carrying
10// the text inline (a quoted word, or a $(cat <<'EOF' ... EOF) heredoc) and the flags naming a file it is read from
11export type TextSource = { fields: string[]; when?: Record<string, string>; sets?: Settings } | { command: string; body: string[]; file?: string[] };
12
13// one place text leaves the session: a name config replaces it by, a regex over the tool name, where the
14// text is, the channel's hard length limit, what the text is in the words a rules question names it by, and the kind
15// of text it is, which decides the rules that govern it (every rule when unset)
16export type Channel = { name: string; tool: string; text: TextSource; limit?: number; kind?: string; textKind?: TextKind };
17
18// what a call carries: the text itself, or the file it will be read from
19export type Body = { text: string } | { file: string };
20
21const DISCORD_LIMIT = 2000;
22
23export const DISCORD_CHANNELS: Channel[] = [
24 { name: 'discord-message', tool: '^mcp__discord__(send_message|edit_message|send_webhook_message)$', text: { fields: ['content'] }, limit: DISCORD_LIMIT, kind: 'a Discord message', textKind: 'message' },
25 { name: 'discord-dm', tool: '^mcp__discord__send_dm$', text: { fields: ['content', 'message'] }, limit: DISCORD_LIMIT, kind: 'a Discord direct message', textKind: 'message' },
26 { name: 'discord-forum-post', tool: '^mcp__discord__create_forum_post$', text: { fields: ['content', 'message'] }, limit: DISCORD_LIMIT, kind: 'a Discord forum post', textKind: 'message' },
27 { name: 'discord-embed', tool: '^mcp__discord__(send_embed|send_dm_embed)$', text: { fields: ['description', 'title'] }, limit: DISCORD_LIMIT, kind: 'a Discord embed', textKind: 'message' },
28];
29
30// how an artifact is named when no forge is bound to name it
31const PLAIN_NOUNS: Record<ForgeArtifact, string> = { issue: 'issue', pr: 'pull request', release: 'release' };
32
33// what the text is, in the words the judge reads: "the title and body of a new GitHub issue"
34export function textAbout(artifact: ForgeWrite, nouns: Record<ForgeArtifact, string> = PLAIN_NOUNS): string {
35 const noun = nouns[artifact.kind];
36 const body = artifact.kind === 'release' ? 'notes' : 'body';
37 if (artifact.action === 'comment') return `a comment on a ${noun}`;
38 if (artifact.action === 'review') return `a review on a ${noun}`;
39 if (artifact.action === 'merge') return 'a merge commit message';
40 if (artifact.action === 'edit') return `the edited title and ${body} of a ${noun}`;
41 return `the title and ${body} of a new ${noun}`;
42}
43
44// the kind of text a write makes: a comment or review is a comment, a merge makes a commit
45export function textKindOf(write: ForgeWrite): TextKind {
46 if (write.action === 'comment' || write.action === 'review') return 'comment';
47 if (write.action === 'merge') return 'commit';
48 return write.kind;
49}
50
51// what each post sets beside its text, by its kind, as the post tool names the fields
52const POST_SETTINGS: Record<string, Settings> = {
53 'pr-create': { base: 'value', head: 'value', draft: 'flag' },
54 'pr-review': { verdict: 'value' },
55 'pr-merge': { method: 'value' },
56 'release-create': { tag: 'value', target: 'value', draft: 'flag', prerelease: 'flag' },
57 'release-edit': { tag: 'value' },
58};
59
60export const POST_TOOL = 'mcp__sift__post';
61
62// the post tool's kind for a write: pr-comment
63export const postKind = (w: ForgeWrite): string => `${w.kind}-${w.action}`;
64
65// one channel per write the post tool makes, named forge-artifact-action
66export function postChannels(forge: Forge): Channel[] {
67 const prefix = forge.name.toLowerCase();
68 return forge.writes.map((w) => {
69 const sets = POST_SETTINGS[postKind(w)];
70 return { name: `${prefix}-${postKind(w)}`, tool: `^${POST_TOOL}$`, text: { fields: ['title', 'body'], when: { kind: postKind(w) }, ...(sets ? { sets } : {}) }, kind: textAbout(w, forge.nouns), textKind: textKindOf(w) };
71 });
72}
73
74// one channel per write the forge's cli makes from the shell, named forge-shell-artifact-action. not in the default
75// table: a shell write is refused toward post, and judged on these only for a loop that has no post to make it with
76export function shellChannels(forge: Forge): Channel[] {
77 const prefix = forge.name.toLowerCase();
78 return forge.writes.map((w) => ({ name: `${prefix}-shell-${postKind(w)}`, tool: '^Bash$', text: forge.cliText(w), kind: textAbout(w, forge.nouns), textKind: textKindOf(w) }));
79}
80
81export function defaultChannels(forge?: Forge): Channel[] {
82 return forge ? [...DISCORD_CHANNELS, ...postChannels(forge)] : [...DISCORD_CHANNELS];
83}
84
85// an entry with a known name takes that entry's place in the order, any other is appended
86export function channelTable(defaults: Channel[], over: Channel[]): Channel[] {
87 const table = [...defaults];
88 for (const c of over) {
89 const at = table.findIndex((d) => d.name === c.name);
90 if (at < 0) table.push(c);
91 else table[at] = c;
92 }
93 return table;
94}
95
96const flagPattern = (flags: string[]): RegExp => new RegExp(String.raw`(?:^|\s)(?:${flags.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?:=|\s+)`);
97
98// a heredoc on the command's stdin: the lines between the << line and the delimiter alone on its own line
99const STDIN_HEREDOC = /<<-?\s*['"]?(\w+)['"]?[^\n]*\n([\s\S]*?)\n[ \t]*\1[ \t]*(?:\n|$)/;
100
101// the value after a body flag: a quoted word, or a heredoc inside $(cat <<'EOF' ... EOF); else the path after a file flag,
102// or the heredoc on stdin when that path is -
103export function commandBody(command: string, source: Extract<TextSource, { command: string }>): Body | undefined {
104 const flag = flagPattern(source.body).exec(command);
105 if (flag) {
106 const rest = command.slice(flag.index + flag[0].length);
107 const heredoc = /^"?\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\s*\n?\s*\)/.exec(rest);
108 if (heredoc) return { text: heredoc[2]! };
109 const text = shellWord(rest);
110 return text === undefined ? undefined : { text };
111 }
112 if (!source.file || source.file.length === 0) return undefined;
113 const fileFlag = flagPattern(source.file).exec(command);
114 if (!fileFlag) return undefined;
115 const file = shellWord(command.slice(fileFlag.index + fileFlag[0].length));
116 if (file === undefined) return undefined;
117 if (file !== '-') return { file };
118 const stdin = STDIN_HEREDOC.exec(command);
119 return stdin ? { text: stdin[2]! } : { file };
120}
121
122// what a call on the channel sets beside its text, undefined when the channel names nothing it sets
123export function settingsOf(channel: Channel, input: Record<string, unknown>): Record<string, string | boolean> | undefined {
124 if (!('fields' in channel.text) || !channel.text.sets) return undefined;
125 const out: Record<string, string | boolean> = {};
126 for (const [field, how] of Object.entries(channel.text.sets)) {
127 const v = input[field];
128 if (how === 'flag') out[field] = v === true;
129 else if (typeof v === 'string' && v.length > 0) out[field] = v;
130 }
131 return out;
132}
133
134// the text a call sends through the channel, undefined when the call is not on it or carries none
135export function textOf(channel: Channel, tool: string, input: Record<string, unknown>): Body | undefined {
136 if (!new RegExp(channel.tool).test(tool)) return undefined;
137 if ('fields' in channel.text) {
138 if (Object.entries(channel.text.when ?? {}).some(([k, v]) => input[k] !== v)) return undefined;
139 const parts = channel.text.fields.map((f) => input[f]).filter((v): v is string => typeof v === 'string' && v.length > 0);
140 return parts.length > 0 ? { text: parts.join('\n') } : undefined;
141 }
142 const command = input['command'];
143 if (typeof command !== 'string' || !new RegExp(channel.text.command).test(command)) return undefined;
144 return commandBody(command, channel.text);
145}
146src/gate/outbound.ts 196 lines1import { ruleSource, type GradeHost } from '../grade.ts';
2import type { Judge } from '../judge/types.ts';
3import { runParts } from '../packs/run.ts';
4import type { Pack, Report, Subject } from '../packs/types.ts';
5import type { Checkout } from '../repo/checkout.ts';
6import type { RepoConfig } from '../repo/config.ts';
7import { textRulesSubjects, type TextTarget } from '../repo/subjects.ts';
8import type { Rule, RuleSource } from '../rules/discover.ts';
9import type { TextKind } from '../rules/kinds.ts';
10import { breachText, confirmBreaches } from './breaches.ts';
11import { channelTable, defaultChannels, settingsOf, textOf, type Channel } from './channels.ts';
12import { verdictKey, type Verdicts } from './verdicts.ts';
13
14// text a tool call is about to send somewhere people read, the hard limit of that channel, what the text is in prose
15// and as the kind of text rules govern, and what the write sets beside it (a pull request's base, head and draft);
16// denied names the reason the text could not be obtained at all, which the gate refuses without a judge call
17export type Outbound = { channel: string; text: string; limit?: number; kind?: string; textKind?: TextKind; sets?: Record<string, string | boolean>; denied?: string };
18
19export type ReadText = (path: string) => Promise<string>;
20
21// the first channel the call is on decides; a body named by file is read here so the gate judges exactly what the command will send
22export async function outboundOf(tool: string, input: Record<string, unknown>, read: ReadText, through: Channel[] = defaultChannels()): Promise<Outbound | undefined> {
23 for (const c of through) {
24 const got = textOf(c, tool, input);
25 if (got === undefined) continue;
26 const sets = settingsOf(c, input);
27 const base = { channel: c.name, limit: c.limit, kind: c.kind, ...(c.textKind ? { textKind: c.textKind } : {}), ...(sets ? { sets } : {}) };
28 if ('text' in got) return { ...base, text: got.text };
29 if (got.file === '-') return { ...base, text: '', denied: 'the body is read from stdin (--body-file -) with no heredoc in the command, so it cannot be judged; pass --body, a file path or a heredoc' };
30 try {
31 return { ...base, text: await read(got.file) };
32 } catch (err) {
33 return { ...base, text: '', denied: `the body file ${got.file} cannot be read (${err instanceof Error ? err.message : String(err)})` };
34 }
35 }
36 return undefined;
37}
38
39// report is the text's, one report of every part when it was judged in parts.
40// pending: the text is not judged in full yet; it settles to why it could not be, or undefined, and settleDecision then
41// judges it again. confirming: what it waits on is the check of the rules the first round found broken, named here,
42// rather than the rules themselves being found
43export type OutboundDecision = { allow: boolean; reason: string; report?: Report; warnings: string[]; pending?: Promise<string | undefined>; confirming?: string[] };
44
45// the channel's length limit is mechanical; the rules are judged on every part of the text, an unclear rule warns, and
46// each is named by the parts it was found in when the text was judged in parts. a rule violated in any part is asked
47// again beside the rest of its document and the lines that break it found, off the hook's clock: the decision answers
48// pending on that, and the text judged again once it lands meets the verdict it kept. a verdict judged in full is
49// kept, and the same text under the same rules meets it again without a judge call
50export async function gateOutbound(out: Outbound, subjects: Subject[], pack: Pack, judge: Judge, config: RepoConfig, verdicts: Verdicts): Promise<OutboundDecision> {
51 if (out.denied !== undefined) return { allow: false, reason: out.denied, warnings: [] };
52 if (out.limit !== undefined && out.text.length > out.limit) {
53 return { allow: false, reason: `${out.channel} text is ${out.text.length} chars, the limit is ${out.limit}`, warnings: [] };
54 }
55 // unjudged text is never let through for want of time: the rules are still being found, and the caller settles the decision once they are
56 const pending = subjects.find((s) => s.pending !== undefined);
57 if (pending) return { allow: false, reason: pending.pending!, warnings: [], pending: pending.settled ?? Promise.resolve(undefined) };
58 const key = verdictKey(out, subjects, pack, judge);
59 const kept = await verdicts.get(key);
60 if (kept) return kept;
61 const flight = verdicts.inFlight(key);
62 if (flight && 'settled' in flight) return flight.settled;
63 if (flight) return confirming(flight.running, [], []);
64 const rules = (subjects[0]?.facts['rules'] as Rule[] | undefined) ?? [];
65 const report = await runParts(pack, subjects, judge, config);
66 if (report.judgeError) return { allow: true, reason: `judge unavailable (${report.judgeError})`, report, warnings: [] };
67 // a rule is asked in the opening and again in each later part, under another question; it is named once, with every part it was found in
68 const unclear = new Map<string, string[]>();
69 const violated: Rule[] = [];
70 for (const item of report.ranked[0]?.items ?? []) {
71 const rule = rules[item.index];
72 if (rule === undefined) continue;
73 for (const j of item.asked) if (j.band === 'unclear' && j.severity !== 'info') unclear.set(rule.text, [...(unclear.get(rule.text) ?? []), ...(j.parts ?? [])]);
74 if (item.asked.some((j) => j.band === 'violated' && j.severity !== 'info')) violated.push(rule);
75 }
76 if (violated.length === 0) {
77 const verdict = { allow: true, reason: 'clear', warnings: warningsOf(unclear) };
78 await verdicts.set(key, verdict);
79 return { ...verdict, report };
80 }
81 const work = confirmBreaches(report, subjects, judge).then(async (confirmed) => {
82 if (!confirmed.ok) return { verdict: { allow: true, reason: `judge unavailable (${confirmed.error})`, warnings: [] }, kept: false };
83 // a rule that breaks the text is not also a warning
84 for (const u of confirmed.unclear) unclear.set(u.rule.text, [...(unclear.get(u.rule.text) ?? []), ...u.parts]);
85 for (const b of confirmed.breaches) unclear.delete(b.rule.text);
86 const verdict = confirmed.breaches.length > 0 ? { allow: false, reason: `breaks: ${confirmed.breaches.map(breachText).join(' | ')}`, warnings: warningsOf(unclear) } : { allow: true, reason: 'clear', warnings: warningsOf(unclear) };
87 await verdicts.set(key, verdict);
88 return { verdict, kept: true };
89 });
90 return confirming(verdicts.settle(key, work), violated, warningsOf(unclear), report);
91}
92
93// a decision pending on the check of the rules the first round found broken, named by their documents
94function confirming(running: Promise<unknown>, violated: Rule[], warnings: string[], report?: Report): OutboundDecision {
95 const names = violated.map((r) => `${r.source} ${JSON.stringify(r.text)}`);
96 return {
97 allow: false,
98 reason: names.length > 0 ? `may break: ${names.join(' | ')}` : 'the rules this text may break are being checked',
99 warnings,
100 ...(report ? { report } : {}),
101 pending: running.then(
102 () => undefined,
103 (error: unknown) => `the check of the rules it may break failed: ${error instanceof Error ? error.message : String(error)}`,
104 ),
105 confirming: names,
106 };
107}
108
109function warningsOf(unclear: Map<string, string[]>): string[] {
110 return [...unclear].map(([rule, at]) => `unclear: ${at.length > 0 ? `${rule} (in ${[...new Set(at)].join(', ')})` : rule}`);
111}
112
113// how many times a pending decision waits: the discovery it was pending on, one more when the documents changed while
114// it ran and the judgement again found a fresh discovery running, and the check of the rules the text may break
115const SETTLE_ROUNDS = 3;
116
117// a pending decision once its rules are known: the text refused unjudged, naming why they could not be found, else
118// judged again, which now reads them
119export async function settleDecision(decision: OutboundDecision, again: () => Promise<OutboundDecision>): Promise<OutboundDecision> {
120 let d = decision;
121 for (let round = 0; d.pending; round++) {
122 if (round === SETTLE_ROUNDS) return { allow: false, reason: `the text was not judged: ${d.reason}`, warnings: [] };
123 const failed = await d.pending;
124 if (failed !== undefined) return { allow: false, reason: `the text was not judged: ${failed}`, warnings: [] };
125 d = await again();
126 }
127 return d;
128}
129
130// what a pending decision came to once its rules were known: the decision, the action the log records and the text
131// the caller is told
132// handedOver: another environment makes the post and tells its caller, so this one only logs the text
133export type Later = { decision?: OutboundDecision; action: string; text: string; handedOver?: boolean };
134
135// the verdict on text judged before its rules were known, once they are, told under head: the advice on text sent
136// under advise, or under enforce the verdict a call refused while they were found would meet
137export function verdictLater(mode: Exclude<OutboundMode, 'off'>, out: Outbound, decision: OutboundDecision, again: () => Promise<OutboundDecision>, head: string): Promise<Later> {
138 return settleDecision(decision, again).then(
139 (d): Later => ({ decision: d, action: enact(mode, d, false).action, text: `${head}: ${verdictOf(out, d)}` }),
140 (error: unknown): Later => ({ action: 'fail', text: `${head}: the text was not judged: ${error instanceof Error ? error.message : String(error)}` }),
141 );
142}
143
144// how outbound text is held to the rules: off judges nothing, advise judges and lets everything through with the
145// verdict attached, enforce refuses a broken rule
146export const OUTBOUND_MODES = ['off', 'advise', 'enforce'] as const;
147export type OutboundMode = (typeof OUTBOUND_MODES)[number];
148
149// what a mode makes of a decision: the action the decision log records, and whether the call is refused. an override
150// lets an enforced refusal through; shadow refuses nothing and attaches nothing. a pending decision is recorded
151// pending, and again as its verdict once its rules are known
152export function enact(mode: OutboundMode, decision: OutboundDecision, shadow: boolean, override?: string): { action: string; refuse: boolean; advise: boolean } {
153 if (mode === 'advise') return { action: decision.allow ? 'allow' : shadow ? 'would-advise' : decision.pending ? 'pending' : 'advise', refuse: false, advise: !shadow };
154 if (decision.allow) return { action: 'allow', refuse: false, advise: false };
155 if (override !== undefined) return { action: 'override', refuse: false, advise: false };
156 if (shadow) return { action: 'would-deny', refuse: false, advise: false };
157 return { action: decision.pending ? 'pending' : 'deny', refuse: true, advise: false };
158}
159
160// the verdict an advised call carries back to its caller
161export function verdictOf(out: Outbound, decision: OutboundDecision): string {
162 const head = decision.allow ? `sift outbound (${out.channel}): ${decision.reason}` : `sift outbound (${out.channel}), note: ${decision.reason.replace(/^breaks: /, 'this may break ')}`;
163 return [head, ...decision.warnings].join('; ');
164}
165
166export type GateHost = Pick<GradeHost, 'forge' | 'fs' | 'judge' | 'discoveries'> & { verdicts: Verdicts };
167
168// checkout: the one whose rules judged the text
169export type Gated = { outbound: Outbound; decision: OutboundDecision; checkout: Checkout };
170
171// one tool call under the checkout it is made from: that checkout's channel table, rules pack and rule documents;
172// undefined when the call sends no text or the checkout has no rules pack
173export async function gateCall(host: GateHost, checkout: Checkout, tool: string, input: Record<string, unknown>, read: ReadText): Promise<Gated | undefined> {
174 const outbound = await outboundOf(tool, input, read, channelTable(defaultChannels(host.forge), checkout.config.outbound.channels));
175 return outbound ? gateText(host, checkout, outbound) : undefined;
176}
177
178// text on its way out under the checkout's rules pack and rule documents; undefined when the checkout has no rules pack
179export async function gateText(host: GateHost, checkout: Checkout, outbound: Outbound): Promise<Gated | undefined> {
180 const pack = checkout.packs['rules'];
181 if (!pack) return undefined;
182 const subjects = await textRulesSubjects({ forge: host.forge, repo: checkout.repo, source: rulesOf(host, checkout), discoveries: host.discoveries }, outboundTarget(outbound), checkout.config);
183 return { outbound, decision: await gateOutbound(outbound, subjects, pack, host.judge, checkout.config, host.verdicts), checkout };
184}
185
186// what the rules read of outbound text: the text, what it is and what the write sets beside it
187export function outboundTarget(out: Outbound): TextTarget {
188 return { text: out.text, ...(out.kind ? { about: out.kind } : {}), ...(out.textKind ? { kind: out.textKind } : {}), ...(out.sets ? { sets: out.sets } : {}) };
189}
190
191// a directory in no repository has no rule documents of its own; entries the config names in another repository still read from the forge
192function rulesOf(host: GateHost, checkout: Checkout): RuleSource {
193 if (checkout.git || checkout.repo) return ruleSource(host, { checkout, named: false }, checkout.repo);
194 return { scope: checkout.root, list: async () => [], read: async () => undefined, template: () => false, remote: (repo, path, ref) => host.forge.file(repo, path, ref) };
195}
196src/gate/held.ts 51 lines1import type { StoreLike } from '../log.ts';
2import type { PostInput } from './post.ts';
3
4// a post held while its rules are found: the caller it answers (unset for the main loop) and the input it was made with
5export type HeldPost = { id: string; to?: string; input: PostInput; at: number };
6
7export type HeldHost = {
8 store: StoreLike;
9 // the session's held posts key
10 key: string;
11 // whether this environment still holds the session
12 holds: () => Promise<boolean>;
13 now: () => number;
14 id: () => string;
15};
16
17// the session's held posts, kept in the store so a reload hands them on. the environment that held a post makes it
18// only after claiming it, which fails once a reload replaced that environment; the one that replaced it takes over
19// every post still held and makes it itself, so a held post is made once and its caller always hears how it went
20export class HeldPosts {
21 constructor(private readonly host: HeldHost) {}
22
23 async keep(to: string | undefined, input: PostInput): Promise<string> {
24 // a write from a replaced environment is dropped, and the post with it
25 if (!(await this.host.holds())) throw new Error('a reload replaced this sift environment while the post was made, so it was not held and nothing was written. Make the post again');
26 const id = this.host.id();
27 await this.host.store.set(this.host.key, [...(await this.all()), { id, ...(to === undefined ? {} : { to }), input, at: this.host.now() }]);
28 return id;
29 }
30
31 // true when this environment still holds the session and the post, which it now makes; false once a reload took it over
32 async claim(id: string): Promise<boolean> {
33 if (!(await this.host.holds())) return false;
34 const all = await this.all();
35 if (!all.some((p) => p.id === id)) return false;
36 await this.host.store.set(this.host.key, all.filter((p) => p.id !== id));
37 return true;
38 }
39
40 // every post an environment a reload replaced left held, taken to be made here
41 async takeOver(): Promise<HeldPost[]> {
42 const all = await this.all();
43 if (all.length > 0) await this.host.store.set(this.host.key, []);
44 return all;
45 }
46
47 private async all(): Promise<HeldPost[]> {
48 return ((await this.host.store.get(this.host.key)) as HeldPost[] | undefined) ?? [];
49 }
50}
51src/gate/post.ts 179 lines1import type { Forge, ForgePost, ForgeWrite, MergeMethod, ReviewVerdict } from '../forge/forge.ts';
2import type { Judge } from '../judge/types.ts';
3import type { Pack } from '../packs/types.ts';
4import type { RepoConfig } from '../repo/config.ts';
5import { textRulesSubjects } from '../repo/subjects.ts';
6import { forgeSource, type Discoveries } from '../rules/discover.ts';
7import { simpleCommands } from '../shell.ts';
8import { channelTable, defaultChannels, POST_TOOL, postKind, textAbout } from './channels.ts';
9import { enact, gateOutbound, outboundOf, outboundTarget, settleDecision, verdictLater, verdictOf, type Later, type Outbound, type OutboundDecision, type OutboundMode } from './outbound.ts';
10import type { Verdicts } from './verdicts.ts';
11
12export const VERDICTS: ReviewVerdict[] = ['approve', 'request-changes', 'comment'];
13export const METHODS: MergeMethod[] = ['merge', 'squash', 'rebase'];
14
15// the post tool's input, as the model sends it
16export type PostInput = {
17 repo?: unknown;
18 kind?: unknown;
19 number?: unknown;
20 tag?: unknown;
21 title?: unknown;
22 body?: unknown;
23 base?: unknown;
24 head?: unknown;
25 draft?: unknown;
26 verdict?: unknown;
27 method?: unknown;
28 target?: unknown;
29 prerelease?: unknown;
30 override?: unknown;
31};
32
33// the repository and the write a post names, or what is missing or malformed in it
34export function postOf(input: PostInput, forge: Pick<Forge, 'writes'>): { repo: string; post: ForgePost; override?: string } | { error: string } {
35 const repo = typeof input.repo === 'string' ? input.repo.trim() : '';
36 if (!/^[^/\s]+\/[^\s]+$/.test(repo)) return { error: `repo is the repository to write to as owner/name, got ${JSON.stringify(input.repo)}` };
37 const kinds = forge.writes.map(postKind);
38 const write = forge.writes.find((w) => postKind(w) === input.kind);
39 if (!write) return { error: `kind is one of ${kinds.join(', ')}, got ${JSON.stringify(input.kind)}` };
40 const str = (k: keyof PostInput): string | undefined => (typeof input[k] === 'string' && (input[k] as string).length > 0 ? (input[k] as string) : undefined);
41 const bool = (k: keyof PostInput): boolean | undefined => (typeof input[k] === 'boolean' ? (input[k] as boolean) : undefined);
42 const missing = (...names: string[]) => ({ error: `${input.kind} needs ${names.join(' and ')}` });
43 const number = typeof input.number === 'number' ? input.number : typeof input.number === 'string' && /^#?\d+$/.test(input.number) ? Number(input.number.replace('#', '')) : undefined;
44 const numbered = write.kind !== 'release' && write.action !== 'create';
45 if (numbered && (number === undefined || !Number.isInteger(number) || number <= 0)) return missing('number, the issue or pull request number');
46 const tag = str('tag');
47 if (write.kind === 'release' && tag === undefined) return missing('tag');
48 const [title, body] = [str('title'), str('body')];
49 const post = ((): ForgePost | { error: string } => {
50 switch (write.action) {
51 case 'create':
52 if (write.kind === 'release') return body === undefined ? missing('body, the release notes') : { kind: 'release', action: 'create', tag: tag!, body, title, target: str('target'), draft: bool('draft'), prerelease: bool('prerelease') };
53 if (title === undefined || body === undefined) return missing('title', 'body');
54 if (write.kind === 'issue') return { kind: 'issue', action: 'create', title, body };
55 if (str('base') === undefined || str('head') === undefined) return missing('base', 'head');
56 return { kind: 'pr', action: 'create', title, body, base: str('base')!, head: str('head')!, draft: bool('draft') };
57 case 'comment':
58 return body === undefined ? missing('body') : { kind: write.kind as 'issue' | 'pr', action: 'comment', number: number!, body };
59 case 'edit':
60 if (title === undefined && body === undefined) return missing('title or body');
61 return write.kind === 'release' ? { kind: 'release', action: 'edit', tag: tag!, title, body } : { kind: write.kind, action: 'edit', number: number!, title, body };
62 case 'review': {
63 const verdict = VERDICTS.find((v) => v === input.verdict);
64 if (!verdict) return { error: `pr-review needs verdict, one of ${VERDICTS.join(', ')}` };
65 return { kind: 'pr', action: 'review', number: number!, verdict, body };
66 }
67 case 'merge': {
68 const method = METHODS.find((m) => m === input.method);
69 if (!method) return { error: `pr-merge needs method, one of ${METHODS.join(', ')}` };
70 return { kind: 'pr', action: 'merge', number: number!, method, title, body };
71 }
72 }
73 })();
74 if ('error' in post) return post;
75 if (input.override !== undefined && (typeof input.override !== 'string' || input.override.trim().length === 0)) return { error: `override is the reason the write goes through over the ruling, got ${JSON.stringify(input.override)}` };
76 return typeof input.override === 'string' ? { repo, post, override: input.override.trim() } : { repo, post };
77}
78
79export type PostHost = {
80 forge: Forge;
81 judge: Judge;
82 // the rule discoveries in flight, shared with every grade of the session
83 discoveries: Discoveries;
84 // the gate's kept verdicts, shared with every gate of the session
85 verdicts: Verdicts;
86 // the conventions of a repository on the forge
87 config: (repo: string) => Promise<RepoConfig>;
88 // keeps a post held while its rules are found, answering the claim to make before writing it: false once a reload
89 // handed the post to the environment that replaced this one. unset, a held post is only held in memory
90 hold?: (input: PostInput) => Promise<() => Promise<boolean>>;
91};
92
93// action is what the decision log records, verdict what an advised or overridden write carries back to the caller.
94// held: the write waits for its rules; later settles to what the caller is told once they are known, the advice on a
95// write made at once or the outcome of a held one
96export type Posted = { outbound?: Outbound; decision?: OutboundDecision; action?: string; override?: string; later?: Promise<Later> } & ({ url: string; verdict?: string } | { refused: string } | { held: string });
97
98const noFile = async (): Promise<string> => {
99 throw new Error('the post tool takes its text inline');
100};
101
102// one post under the repository it names: that repository's conventions, channels and rule documents, read from the
103// forge whatever the caller's working directory is. the mode decides what a limit or a broken rule does: off judges
104// nothing, advise writes with the verdict attached, enforce refuses unless the post carries an override. when the
105// text is not judged in full yet (its rules are still being found, or the rules it may break are being checked off the
106// hook's clock), advise writes at once and the verdict follows in later, and enforce holds the write until it is, so
107// the caller never sends the text twice
108export async function postCall(host: PostHost, pack: Pack | undefined, input: PostInput, mode: OutboundMode, shadow = false): Promise<Posted> {
109 const parsed = postOf(input, host.forge);
110 if ('error' in parsed) return { refused: parsed.error };
111 const { repo, post, override } = parsed;
112 if (mode === 'off') return { url: await host.forge.post(repo, post) };
113 const config = await host.config(repo);
114 const outbound = await outboundOf(POST_TOOL, input as Record<string, unknown>, noFile, channelTable(defaultChannels(host.forge), config.outbound.channels));
115 if (!outbound || !pack) return { outbound, url: await host.forge.post(repo, post) };
116 const judged = async () => gateOutbound(outbound, await textRulesSubjects({ forge: host.forge, repo, source: forgeSource(host.forge, repo), discoveries: host.discoveries }, outboundTarget(outbound), config), pack, host.judge, config, host.verdicts);
117 const decision = await judged();
118 const { action, refuse, advise } = enact(mode, decision, shadow, override);
119 const label = postLabel(repo, post, String(input.kind));
120 if (decision.pending && !shadow && action !== 'override') {
121 if (mode === 'advise') {
122 const url = await host.forge.post(repo, post);
123 const later = verdictLater(mode, outbound, decision, judged, `sift outbound advice on the ${label} written at ${url}, now that ${decision.confirming ? 'it is checked' : 'its rules are known'}`);
124 return { outbound, decision, action, url, later, verdict: pendingNote(outbound, decision, repo) };
125 }
126 const claim = host.hold ? await host.hold(input) : async () => true;
127 const later = settleDecision(decision, judged).then(
128 async (d): Promise<Later> => {
129 if (!(await claim())) return { decision: d, action: 'handed-over', text: `sift post handed the held ${label} to the environment that replaced this one on a reload`, handedOver: true };
130 const done = enact(mode, d, shadow);
131 if (done.refuse) return { decision: d, action: done.action, text: `sift post refused the held ${label}, and nothing was written: ${outbound.channel} to ${repo}: ${d.reason}. Rewrite the text, or post again with override set to the reason it should go through as written.` };
132 return { decision: d, action: done.action, text: `sift post wrote the held ${label}: ${await host.forge.post(repo, post)}` };
133 },
134 ).catch((error: unknown): Later => ({ action: 'fail', text: `sift post failed on the held ${label}: ${messageOf(error)}` }));
135 const held = decision.confirming
136 ? `the ${label} is held while the rules it may break are checked beside the rest of their documents and the lines that break them are found (${decision.reason}). It is written or refused once that is done, and the url or the refusal follows`
137 : `the ${label} is held while the rules of ${repo} are still being found. It is judged and written once they are known, and the url or the refusal follows`;
138 return { outbound, decision, action: 'hold', later, held };
139 }
140 if (refuse) return { outbound, decision, action, refused: `${outbound.channel} to ${repo}: ${decision.reason}` };
141 const url = await host.forge.post(repo, post);
142 if (action === 'override') return { outbound, decision, action, override, url, verdict: `sift outbound (${outbound.channel}): written over the ruling (${decision.reason}), override: ${override}` };
143 return { outbound, decision, action, url, ...(advise ? { verdict: verdictOf(outbound, decision) } : {}) };
144}
145
146// the write a post makes, for the caller told of it later: its kind and what it is on
147export function postLabel(repo: string, post: ForgePost, kind: string): string {
148 if (post.kind === 'release') return `${kind} ${post.tag} on ${repo}`;
149 if (post.action === 'create') return `${kind} "${post.title}" on ${repo}`;
150 return `${kind} on ${repo}#${post.number}`;
151}
152
153// what text sent before it was judged in full carries back at once, scope naming whose rules: that they are still being
154// found, or the rules it may break, the checked advice on them following
155export function pendingNote(out: Outbound, decision: OutboundDecision, scope: string): string {
156 if (decision.confirming) return `sift outbound (${out.channel}), note: this ${decision.reason.replace(/^may break: /, 'may break ')}, and the checked advice, quoting the lines that break each rule, follows`;
157 return `sift outbound (${out.channel}): the rules of ${scope} are still being found, so the text went out unjudged and the advice on it follows once they are known`;
158}
159
160function messageOf(error: unknown): string {
161 return error instanceof Error ? error.message : String(error);
162}
163
164// the first write in a shell command the forge's own cli or api makes with text people read
165export function rawWriteOf(forge: Pick<Forge, 'writeOf'>, command: string): ForgeWrite | undefined {
166 for (const words of simpleCommands(command)) {
167 const write = forge.writeOf(words);
168 if (write) return write;
169 }
170 return undefined;
171}
172
173// why the shell write is refused and the post call that makes it instead
174export function rawWriteRefusal(forge: Pick<Forge, 'name' | 'nouns'>, write: ForgeWrite): string {
175 const target = write.kind === 'release' ? 'tag' : write.action === 'create' ? undefined : 'number';
176 const fields = ['repo: "owner/name"', `kind: "${postKind(write)}"`, ...(target ? [target] : []), 'title and body as the write takes them'];
177 return `this command writes ${textAbout(write, forge.nouns)} through the ${forge.name} cli or api, and sift refuses outbound ${forge.name} writes from the shell. Make it with the ${POST_TOOL} tool (${fields.join(', ')}), which judges the text by the rules of the repository it names and writes it there`;
178}
179src/gate/shell.ts 38 lines1import type { Forge, ForgeWrite } from '../forge/forge.ts';
2import type { Checkout } from '../repo/checkout.ts';
3import { channelTable, POST_TOOL, shellChannels, textAbout } from './channels.ts';
4import { gateText, outboundOf, type GateHost, type Gated, type OutboundMode, type ReadText } from './outbound.ts';
5import { rawWriteRefusal } from './post.ts';
6
7// what the gate does with a forge write from the shell. under enforce a loop that can call post is refused and pointed
8// at it. one that cannot (spawned before post was registered, and a subagent keeps the tools it was spawned with) has
9// its text judged by the checkout's rules instead, so the gate never refuses a write without a way through that the
10// caller has. under advise every shell write has its text judged and runs whatever the verdict; unread says why its
11// text could not be judged
12export type ShellGate =
13 | { write: ForgeWrite; fallback: boolean; refused: string }
14 | { write: ForgeWrite; fallback: boolean; gated?: Gated; unread?: string };
15
16export async function gateShellWrite(host: GateHost, write: ForgeWrite, canPost: boolean, mode: Exclude<OutboundMode, 'off'>, checkout: () => Promise<Checkout>, input: Record<string, unknown>, read: ReadText): Promise<ShellGate> {
17 if (mode === 'enforce' && canPost) return { write, fallback: false, refused: rawWriteRefusal(host.forge, write) };
18 const fallback = !canPost;
19 const at = await checkout();
20 const outbound = await outboundOf('Bash', input, read, channelTable(shellChannels(host.forge), at.config.outbound.channels));
21 if (!outbound) return mode === 'enforce' ? { write, fallback, refused: unreadable(host.forge, write) } : { write, fallback, unread: unjudged(host.forge, write) };
22 return { write, fallback, gated: await gateText(host, at, outbound) };
23}
24
25// why the judged text was let through or not, for a loop that cannot call post
26export function fallbackNote(forge: Pick<Forge, 'name'>): string {
27 return `This loop started before sift registered ${POST_TOOL}, so it cannot call it: its ${forge.name} write from the shell was judged on its text by the checkout's rules instead of refused`;
28}
29
30function unreadable(forge: Pick<Forge, 'name' | 'nouns' | 'cliText'>, write: ForgeWrite): string {
31 const cli = forge.cliText(write);
32 return `this command writes ${textAbout(write, forge.nouns)} through the ${forge.name} cli or api, and this loop started before sift registered ${POST_TOOL}, so it cannot call it. Its text is judged by the checkout's rules instead, and could not be read from this command: make the write with the ${forge.name} cli, the text after ${cli.body[0]} as a quoted word or a $(cat <<'EOF' ... EOF) heredoc, or in a file named by ${cli.file[0]}`;
33}
34
35function unjudged(forge: Pick<Forge, 'name' | 'nouns'>, write: ForgeWrite): string {
36 return `sift outbound: this command writes ${textAbout(write, forge.nouns)} through the ${forge.name} cli or api, and its text could not be read from the command, so it was not judged. ${POST_TOOL} judges the text of every write it makes`;
37}
38src/gate/verdicts.ts 86 lines1import { digest } from '../hash.ts';
2import type { Judge } from '../judge/types.ts';
3import type { StoreLike } from '../log.ts';
4import type { Pack, Subject } from '../packs/types.ts';
5import { CONFIRM_QUESTION, PASSAGE_QUESTION } from './breaches.ts';
6import type { Outbound } from './outbound.ts';
7
8// bump when what a kept verdict holds, or how the gate reads a report into one, changes
9const VERSION = 2;
10const KEY = 'outbound-verdicts';
11const MAX = 500;
12// verdicts settled without being kept, waiting for the call that settles on them
13const MAX_UNKEPT = 100;
14
15// what the gate decided on text it judged in full
16export type Verdict = { allow: boolean; reason: string; warnings: string[] };
17
18type Entry = Verdict & { key: string };
19
20// everything a verdict reads: the text, what it is and where it goes, every part of it with the rules it was judged
21// against (each with its scope), the questions asked of them, the questions a breach is confirmed and located by, and
22// the judge that answered
23export function verdictKey(out: Outbound, subjects: Subject[], pack: Pack, judge: Judge): string {
24 const parts = subjects.map((s) => ({ state: s.state, rules: s.facts['rules'] }));
25 return digest(JSON.stringify({ v: VERSION, channel: out.channel, kind: out.kind, textKind: out.textKind, sets: out.sets, text: out.text, parts, pack: { questions: pack.questions, rank: pack.rank }, breaches: [CONFIRM_QUESTION, PASSAGE_QUESTION], judge: judge.name }));
26}
27
28// the gate's verdicts, kept in the store every session shares. a judge's answers near a band's edge vary between asks,
29// and text sent again unchanged must meet the verdict it met before, so a verdict is answered again for the same key.
30// the last MAX are kept
31export class Verdicts {
32 constructor(private readonly store: StoreLike) {}
33
34 async get(key: string): Promise<Verdict | undefined> {
35 const found = (await this.entries()).find((e) => e.key === key);
36 return found ? { allow: found.allow, reason: found.reason, warnings: found.warnings } : undefined;
37 }
38
39 async set(key: string, verdict: Verdict): Promise<void> {
40 const rest = (await this.entries()).filter((e) => e.key !== key);
41 await this.store.set(KEY, [...rest, { key, ...verdict }].slice(-MAX));
42 }
43
44 // every kept verdict, so text a wrong one met is judged afresh
45 async clear(): Promise<void> {
46 this.settling.clear();
47 await this.store.set(KEY, []);
48 }
49
50 // verdicts still being settled off the hook's clock, by key, in this environment: the one in flight joined by a
51 // call on the same text, and one settled without being kept (the judge failed) answered once to the call that
52 // settles on it
53 private readonly settling = new Map<string, Promise<Verdict>>();
54 private readonly unkept = new Map<string, Verdict>();
55
56 // work that settles the verdict for key; kept says whether it went to the store
57 settle(key: string, work: Promise<{ verdict: Verdict; kept: boolean }>): Promise<Verdict> {
58 const done = work.then(({ verdict, kept }) => {
59 this.settling.delete(key);
60 if (!kept) {
61 this.unkept.set(key, verdict);
62 for (const old of this.unkept.keys()) if (this.unkept.size > MAX_UNKEPT) this.unkept.delete(old);
63 }
64 return verdict;
65 });
66 done.catch(() => this.settling.delete(key));
67 this.settling.set(key, done);
68 return done;
69 }
70
71 // the settle in flight for key, or the verdict one settled without keeping, taken so a later call judges afresh
72 inFlight(key: string): { running: Promise<Verdict> } | { settled: Verdict } | undefined {
73 const running = this.settling.get(key);
74 if (running) return { running };
75 const settled = this.unkept.get(key);
76 if (!settled) return undefined;
77 this.unkept.delete(key);
78 return { settled };
79 }
80
81 private async entries(): Promise<Entry[]> {
82 const got = await this.store.get(KEY);
83 return Array.isArray(got) ? (got as Entry[]) : [];
84 }
85}
86src/forge/github.ts 532 lines1import { Gh, GhError, type ApiResponse } from './gh.ts';
2import type { CwdLike, RunLike } from '../process.ts';
3import type { Check, Comment, Commit, Conditional, Forge, ForgeAction, ForgeArtifact, CliText, ForgeLink, ForgePost, ForgeUser, ForgeWrite, Issue, IssueSummary, PullHead, PullRequest, Rate, Review, ReviewComment, ReviewVerdict, Run, TemplateKind, TreeFile, WatchItem } from './forge.ts';
4
5type GhUser = { login: string; type?: string };
6type GhIssue = {
7 number: number;
8 title: string;
9 body: string | null;
10 state: string;
11 user: GhUser;
12 labels: { name: string }[];
13 milestone: { title: string } | null;
14 html_url: string;
15 pull_request?: unknown;
16 author_association?: string;
17 created_at: string;
18 updated_at: string;
19};
20type GhPull = GhIssue & {
21 base: { ref: string };
22 head: { ref: string; sha: string };
23 draft: boolean;
24 merged: boolean;
25 additions: number;
26 deletions: number;
27 changed_files: number;
28};
29type GhComment = { user: GhUser; body: string | null; created_at: string; author_association?: string };
30type GhCommit = { sha: string; parents: { sha: string }[]; commit: { message: string } };
31type GhCheckRun = { name: string; status: string; conclusion: string | null; details_url?: string | null };
32type GhStatus = { context: string; state: string };
33type GhRun = {
34 id: number;
35 name: string;
36 head_branch: string;
37 event: string;
38 status: string;
39 conclusion: string | null;
40 head_sha: string;
41 html_url: string;
42 // the workflow file the run ran
43 path?: string;
44 actor?: { login: string };
45 updated_at: string;
46};
47
48const RAW = 'application/vnd.github.raw+json';
49const COMMIT_JQ = '[.[] | {sha, parents, commit: {message: .commit.message}}]';
50const PASSING = new Set(['success', 'skipped', 'neutral']);
51// the associations that maintain a repository; contributor, first-timer and none do not
52const MAINTAINING = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
53const COMMENT_JQ = '[.[] | {user: {login: .user.login, type: .user.type}, body, created_at, author_association}]';
54
55// the slim record the items poll produces server side, so a page of 100 stays small
56export const ITEM_JQ = '[.[] | {n: .number, t: .title, s: .state, u: .user.login, ut: .user.type, bl: ((.body // "") | length), bp: ((.body // "")[0:400]), c: .comments, l: ([.labels[].name] | sort | join(",")), up: .updated_at, cr: .created_at, url: .html_url, pr: (.pull_request != null), m: (.pull_request.merged_at != null)}]';
57
58export type RawItem = {
59 n: number;
60 t: string;
61 s: string;
62 u: string;
63 ut?: string;
64 bl: number;
65 bp: string;
66 c: number;
67 l: string;
68 up: string;
69 cr: string;
70 url: string;
71 pr: boolean;
72 m: boolean;
73};
74
75// the html and api urls of an issue or pull request; anything past the number (a comment anchor, /files) is ignored
76const GH_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+\/[^/\s#?]+)\/(issues|pull)\/(\d+)(?:[/?#].*)?$/;
77const GH_API_URL = /^https?:\/\/api\.github\.com\/repos\/([^/\s]+\/[^/\s#?]+)\/(issues|pulls)\/(\d+)(?:[/?#].*)?$/;
78
79export const GH_WRITES: ForgeWrite[] = [
80 ...(['create', 'comment', 'edit', 'review', 'merge'] as const).map((action) => ({ kind: 'pr' as const, action })),
81 ...(['create', 'comment', 'edit'] as const).map((action) => ({ kind: 'issue' as const, action })),
82 ...(['create', 'edit'] as const).map((action) => ({ kind: 'release' as const, action })),
83];
84
85// the flags of each gh write that carry text, and the other names gh takes for a subcommand
86const CLI_TEXT: Record<ForgeArtifact, string[]> = {
87 pr: ['--body', '-b', '--body-file', '-F', '--title', '-t', '--subject'],
88 issue: ['--body', '-b', '--body-file', '-F', '--title', '-t'],
89 release: ['--notes', '-n', '--notes-file', '-F', '--title', '-t'],
90};
91const CLI_ALIASES: Record<string, ForgeAction> = { new: 'create' };
92
93// the gh command of a write, wherever it stands in the shell command, and the flags its text is given by
94export function ghCliText(write: ForgeWrite): CliText {
95 const names = [write.action, ...Object.entries(CLI_ALIASES).flatMap(([alias, action]) => (action === write.action ? [alias] : []))];
96 const noun = write.kind === 'release' ? 'notes' : 'body';
97 return {
98 command: String.raw`(?:^|[\s;&|()/])gh\s+${write.kind}\s+(?:${names.join('|')})\b`,
99 body: [`--${noun}`, `-${noun[0]}`],
100 file: [`--${noun}-file`, '-F'],
101 };
102}
103
104// a create or a comment always sends text; an edit, a review or a merge only when a text flag is given
105const alwaysText = (action: ForgeAction): boolean => action === 'create' || action === 'comment';
106
107// the rest endpoints that write text, by method and path; a path is matched without its leading slash or query
108const API_WRITES: { method: string; path: RegExp; write: ForgeWrite }[] = [
109 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/issues$/, write: { kind: 'issue', action: 'create' } },
110 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/, write: { kind: 'issue', action: 'comment' } },
111 { method: 'PATCH', path: /^repos\/[^/]+\/[^/]+\/issues\/comments\/[^/]+$/, write: { kind: 'issue', action: 'comment' } },
112 { method: 'PATCH', path: /^repos\/[^/]+\/[^/]+\/issues\/[^/]+$/, write: { kind: 'issue', action: 'edit' } },
113 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/pulls$/, write: { kind: 'pr', action: 'create' } },
114 { method: 'PATCH', path: /^repos\/[^/]+\/[^/]+\/pulls\/comments\/[^/]+$/, write: { kind: 'pr', action: 'comment' } },
115 { method: 'PATCH', path: /^repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/, write: { kind: 'pr', action: 'edit' } },
116 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/comments(?:\/[^/]+\/replies)?$/, write: { kind: 'pr', action: 'comment' } },
117 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+\/events)?$/, write: { kind: 'pr', action: 'review' } },
118 { method: 'PUT', path: /^repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews\/[^/]+$/, write: { kind: 'pr', action: 'review' } },
119 { method: 'PUT', path: /^repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge$/, write: { kind: 'pr', action: 'merge' } },
120 { method: 'POST', path: /^repos\/[^/]+\/[^/]+\/releases$/, write: { kind: 'release', action: 'create' } },
121 { method: 'PATCH', path: /^repos\/[^/]+\/[^/]+\/releases\/[^/]+$/, write: { kind: 'release', action: 'edit' } },
122];
123// the request fields that carry text on those endpoints
124const API_TEXT_FIELDS = new Set(['body', 'title', 'name', 'commit_title', 'commit_message']);
125// the graphql mutations that write text, by name
126const GRAPHQL_WRITES: Record<string, ForgeWrite> = {
127 createIssue: { kind: 'issue', action: 'create' },
128 updateIssue: { kind: 'issue', action: 'edit' },
129 addComment: { kind: 'issue', action: 'comment' },
130 updateIssueComment: { kind: 'issue', action: 'comment' },
131 createPullRequest: { kind: 'pr', action: 'create' },
132 updatePullRequest: { kind: 'pr', action: 'edit' },
133 addPullRequestReview: { kind: 'pr', action: 'review' },
134 submitPullRequestReview: { kind: 'pr', action: 'review' },
135 updatePullRequestReview: { kind: 'pr', action: 'review' },
136 addPullRequestReviewComment: { kind: 'pr', action: 'comment' },
137 addPullRequestReviewThread: { kind: 'pr', action: 'comment' },
138 addPullRequestReviewThreadReply: { kind: 'pr', action: 'comment' },
139 updatePullRequestReviewComment: { kind: 'pr', action: 'comment' },
140 mergePullRequest: { kind: 'pr', action: 'merge' },
141 enablePullRequestAutoMerge: { kind: 'pr', action: 'merge' },
142};
143// gh api flags that take a value, so the path is the first word that is neither a flag nor a flag's value
144const API_VALUE_FLAGS = new Set(['-X', '--method', '-H', '--header', '-f', '--raw-field', '-F', '--field', '--input', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-p', '--preview']);
145
146// a flag given as its own word, as flag=value, or as a shorthand with its value glued on
147const hasFlag = (words: string[], flags: string[]): boolean =>
148 words.some((w) => flags.some((f) => w === f || w.startsWith(`${f}=`) || (/^-[A-Za-z]$/.test(f) && w.startsWith(f) && w.length > 2)));
149
150// the write a gh command makes, when it sends text
151export function ghWriteOf(words: string[]): ForgeWrite | undefined {
152 if (words.length < 2 || !/(?:^|\/)gh$/.test(words[0]!)) return undefined;
153 const [, group, sub] = words;
154 if (group === 'api') return ghApiWriteOf(words.slice(2));
155 if (group !== 'pr' && group !== 'issue' && group !== 'release') return undefined;
156 const action = CLI_ALIASES[sub ?? ''] ?? (sub as ForgeAction);
157 if (!GH_WRITES.some((w) => w.kind === group && w.action === action)) return undefined;
158 if (alwaysText(action) || hasFlag(words.slice(3), CLI_TEXT[group])) return { kind: group, action };
159 return undefined;
160}
161
162function ghApiWriteOf(args: string[]): ForgeWrite | undefined {
163 let method: string | undefined;
164 let path: string | undefined;
165 let input = false;
166 const fields: string[] = [];
167 for (let i = 0; i < args.length; i++) {
168 const w = args[i]!;
169 const eq = /^(--?[A-Za-z-]+)=(.*)$/s.exec(w);
170 const [flag, glued] = eq ? [eq[1]!, eq[2]!] : /^-[A-Za-z]./.test(w) && !w.startsWith('--') ? [w.slice(0, 2), w.slice(2)] : [w, undefined];
171 if (!API_VALUE_FLAGS.has(flag)) {
172 if (!w.startsWith('-') && path === undefined) path = w;
173 continue;
174 }
175 const value = glued ?? args[++i] ?? '';
176 if (flag === '-X' || flag === '--method') method = value.toUpperCase();
177 else if (flag === '--input') input = true;
178 else if (flag === '-f' || flag === '--raw-field' || flag === '-F' || flag === '--field') fields.push(value);
179 }
180 if (path === undefined) return undefined;
181 const at = path.replace(/^\//, '').replace(/\?.*$/s, '');
182 if (at === 'graphql') {
183 const mutation = fields.find((f) => /\bmutation\b/.test(f));
184 if (mutation === undefined) return undefined;
185 const named = Object.keys(GRAPHQL_WRITES).find((name) => new RegExp(`\\b${name}\\s*\\(`).test(mutation));
186 return named === undefined ? undefined : GRAPHQL_WRITES[named];
187 }
188 const verb = method ?? (fields.length > 0 || input ? 'POST' : 'GET');
189 const hit = API_WRITES.find((e) => e.method === verb && e.path.test(at));
190 if (!hit) return undefined;
191 const text = input || fields.some((f) => API_TEXT_FIELDS.has(f.split(/[=\[]/, 1)[0]!));
192 return alwaysText(hit.write.action) || text ? hit.write : undefined;
193}
194
195const VERDICTS: Record<ReviewVerdict, string> = { approve: 'APPROVE', 'request-changes': 'REQUEST_CHANGES', comment: 'COMMENT' };
196
197// a request field set only when given, so an edit leaves what it does not name
198const given = <T extends Record<string, unknown>>(fields: T): Partial<T> => Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as Partial<T>;
199
200const user = (u: GhUser): ForgeUser => ({ login: u.login, bot: u.type === 'Bot' || u.login.endsWith('[bot]') });
201
202const comment = (c: GhComment): Comment => ({ author: user(c.user), body: c.body ?? '', createdAt: c.created_at, association: c.author_association });
203
204const commit = (c: GhCommit): Commit => ({ sha: c.sha, message: c.commit.message, merge: c.parents.length > 1 });
205
206const issue = (i: GhIssue): Issue => ({
207 number: i.number,
208 title: i.title,
209 body: i.body ?? '',
210 state: i.state === 'open' ? 'open' : 'closed',
211 author: user(i.user),
212 association: i.author_association,
213 labels: i.labels.map((l) => l.name),
214 milestone: i.milestone?.title,
215 url: i.html_url,
216 createdAt: i.created_at,
217 updatedAt: i.updated_at,
218 pr: i.pull_request != null,
219});
220
221export function toWatchItem(raw: RawItem): WatchItem {
222 return {
223 kind: raw.pr ? 'pr' : 'issue',
224 number: raw.n,
225 title: raw.t,
226 state: raw.s,
227 author: user({ login: raw.u, type: raw.ut }),
228 body: { length: raw.bl, head: raw.bp },
229 comments: raw.c,
230 labels: raw.l ? raw.l.split(',') : [],
231 createdAt: raw.cr,
232 updatedAt: raw.up,
233 merged: raw.m,
234 url: raw.url,
235 };
236}
237
238// an actions check run links its job as .../actions/runs/<run>/job/<job>; a check from any other app names no run
239const ACTIONS_JOB = /\/actions\/runs\/(\d+)\/job\/\d+/;
240
241const checkRun = (c: GhCheckRun): Check => {
242 const run = ACTIONS_JOB.exec(c.details_url ?? '')?.[1];
243 return { name: c.name, done: c.status === 'completed', conclusion: c.conclusion, ok: PASSING.has(c.conclusion ?? ''), ...(run === undefined ? {} : { run }) };
244};
245const status = (s: GhStatus): Check => ({ name: s.context, done: s.state !== 'pending', conclusion: s.state === 'pending' ? null : s.state, ok: s.state === 'success' });
246
247const run = (r: GhRun, tag: boolean): Run => ({
248 id: String(r.id),
249 name: r.name,
250 done: r.status === 'completed',
251 conclusion: r.conclusion,
252 ok: PASSING.has(r.conclusion ?? ''),
253 branch: r.head_branch,
254 sha: r.head_sha,
255 tag,
256 event: r.event,
257 actor: r.actor?.login ?? '',
258 url: r.html_url,
259 updatedAt: r.updated_at,
260});
261
262const rate = (r: ApiResponse): Rate => ({ remaining: r.remaining, reset: r.reset });
263
264const missing = (error: unknown): boolean => error instanceof GhError && /http 40[34]/.test(error.message);
265
266// where github documents templates: a single file or a directory of them, in the root, docs/ or .github/
267const TEMPLATE_DIRS = ['', 'docs', '.github'];
268const TEMPLATE_FILE: Record<TemplateKind, RegExp> = { issue: /^issue_template\.(md|yml|yaml)$/i, pr: /^pull_request_template\.md$/i };
269const TEMPLATE_DIR: Record<TemplateKind, RegExp> = { issue: /^issue_template$/i, pr: /^pull_request_template$/i };
270const TEMPLATE_ENTRY: Record<TemplateKind, RegExp> = { issue: /\.(md|yml|yaml)$/i, pr: /\.md$/i };
271// config.yml beside issue forms configures the chooser, it is no template
272const TEMPLATE_CHOOSER = /^config\.ya?ml$/i;
273
274// the kind of template at a path, from where github documents them; undefined anywhere else
275export function templateKind(path: string): TemplateKind | undefined {
276 const parts = path.split('/');
277 const name = parts.pop()!;
278 const single = parts.length <= 1 && TEMPLATE_DIRS.includes(parts[0] ?? '');
279 const sub = parts.pop();
280 const nested = sub !== undefined && parts.length <= 1 && TEMPLATE_DIRS.includes(parts[0] ?? '');
281 for (const kind of ['issue', 'pr'] as const) {
282 if (single && TEMPLATE_FILE[kind].test(name)) return kind;
283 if (nested && TEMPLATE_DIR[kind].test(sub) && TEMPLATE_ENTRY[kind].test(name) && !TEMPLATE_CHOOSER.test(name)) return kind;
284 }
285 return undefined;
286}
287
288export class GitHubForge implements Forge {
289 readonly name = 'GitHub';
290 readonly nouns: Record<ForgeArtifact, string> = { issue: 'GitHub issue', pr: 'pull request', release: 'GitHub release' };
291 readonly writes = GH_WRITES;
292 readonly gh: Gh;
293 // repo and ref name -> whether a tag of that name exists, read once per name
294 private readonly tagRefs = new Map<string, Promise<boolean>>();
295
296 constructor(run: RunLike, cwd?: CwdLike) {
297 this.gh = new Gh(run, cwd);
298 }
299
300 // a workflow run names its ref without saying whether it is a branch or a tag, so a ref a pull request did not
301 // start is looked up among the tags, once per name
302 private isTag(repo: string, r: GhRun): Promise<boolean> {
303 if (!r.head_branch || r.event.startsWith('pull_request')) return Promise.resolve(false);
304 const key = `${repo}\0${r.head_branch}`;
305 let known = this.tagRefs.get(key);
306 if (!known) {
307 const path = r.head_branch.split('/').map(encodeURIComponent).join('/');
308 known = this.gh
309 .json<{ ref: string }[] | null>(`repos/${repo}/git/matching-refs/tags/${path}`)
310 .then((refs) => (refs ?? []).some((x) => x.ref === `refs/tags/${r.head_branch}`))
311 .catch((error: unknown) => {
312 this.tagRefs.delete(key);
313 if (missing(error)) return false;
314 throw error;
315 });
316 this.tagRefs.set(key, known);
317 }
318 return known;
319 }
320
321 private toRuns(repo: string, raw: GhRun[]): Promise<Run[]> {
322 return Promise.all(raw.map(async (r) => run(r, await this.isTag(repo, r))));
323 }
324
325 async checkout(): Promise<{ repo: string; defaultBranch: string } | undefined> {
326 const info = await this.gh.repoInfo();
327 return info ? { repo: info.nameWithOwner, defaultBranch: info.defaultBranch } : undefined;
328 }
329
330 login(): Promise<string | undefined> {
331 return this.gh.login();
332 }
333
334 async defaultBranch(repo: string): Promise<string> {
335 return (await this.gh.json<{ default_branch: string }>(`repos/${repo}`)).default_branch;
336 }
337
338 async issue(repo: string, number: number): Promise<Issue> {
339 return issue(await this.gh.json<GhIssue>(`repos/${repo}/issues/${number}`));
340 }
341
342 async openIssues(repo: string): Promise<IssueSummary[]> {
343 const all = await this.gh.json<GhIssue[]>(`repos/${repo}/issues?state=open&per_page=100`);
344 return all.filter((i) => !i.pull_request).map((i) => ({ number: i.number, title: i.title }));
345 }
346
347 async parent(repo: string, number: number): Promise<number | undefined> {
348 try {
349 const p = await this.gh.json<{ number: number } | null>(`repos/${repo}/issues/${number}/parent`);
350 return p?.number;
351 } catch {
352 return undefined;
353 }
354 }
355
356 // issue and pull request comments share one endpoint on github. it lists oldest first and takes no sort
357 // or direction, so the newest are the tail of every page
358 async comments(repo: string, _kind: 'issue' | 'pr', number: number, last?: number): Promise<Comment[]> {
359 const all = await this.gh.pages<GhComment>(`repos/${repo}/issues/${number}/comments`, COMMENT_JQ);
360 return (last === undefined ? all : all.slice(Math.max(0, all.length - last))).map(comment);
361 }
362
363 maintains(association: string | undefined): boolean {
364 return association !== undefined && MAINTAINING.has(association);
365 }
366
367 async pull(repo: string, number: number): Promise<PullRequest> {
368 const pr = await this.gh.json<GhPull>(`repos/${repo}/pulls/${number}`);
369 return {
370 ...issue(pr),
371 pr: true,
372 base: pr.base.ref,
373 head: { branch: pr.head.ref, sha: pr.head.sha },
374 draft: pr.draft,
375 merged: pr.merged,
376 stats: { additions: pr.additions, deletions: pr.deletions, files: pr.changed_files },
377 };
378 }
379
380 diff(repo: string, number: number): Promise<string> {
381 return this.gh.text(`repos/${repo}/pulls/${number}`, 'application/vnd.github.diff');
382 }
383
384 async pullCommits(repo: string, number: number): Promise<Commit[]> {
385 return (await this.gh.json<GhCommit[]>(`repos/${repo}/pulls/${number}/commits?per_page=100`)).map(commit);
386 }
387
388 async closingIssues(repo: string, number: number): Promise<number[]> {
389 const [owner, name] = repo.split('/');
390 const query = `query { repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { closingIssuesReferences(first: 50) { nodes { number } } } } }`;
391 const r = await this.gh.json<{ data?: { repository?: { pullRequest?: { closingIssuesReferences?: { nodes: { number: number }[] } } } } }>('graphql', { method: 'POST', fields: { query } });
392 return (r.data?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? []).map((n) => n.number);
393 }
394
395 async reviews(repo: string, number: number, last?: number): Promise<Review[]> {
396 const all = await this.gh.json<{ user: GhUser; state: string; body: string }[]>(`repos/${repo}/pulls/${number}/reviews?per_page=100`);
397 return (last === undefined ? all : all.slice(-last)).map((r) => ({ author: user(r.user), state: r.state, body: r.body }));
398 }
399
400 async reviewComments(repo: string, number: number, last?: number): Promise<ReviewComment[]> {
401 const page = last === undefined ? 'per_page=100' : `per_page=${last}&direction=desc&sort=created`;
402 const got = await this.gh.json<{ user: GhUser; path: string; body: string }[]>(`repos/${repo}/pulls/${number}/comments?${page}`);
403 return (last === undefined ? got : [...got].reverse()).map((c) => ({ author: user(c.user), path: c.path, body: c.body }));
404 }
405
406 async checks(repo: string, sha: string): Promise<Check[]> {
407 const [runs, statuses] = await Promise.all([
408 this.gh.json<{ check_runs: GhCheckRun[] }>(`repos/${repo}/commits/${sha}/check-runs?per_page=100`),
409 this.gh.json<{ statuses: GhStatus[] }>(`repos/${repo}/commits/${sha}/status`),
410 ]);
411 return [...(runs.check_runs ?? []).map(checkRun), ...(statuses.statuses ?? []).map(status)];
412 }
413
414 template(path: string): TemplateKind | undefined {
415 return templateKind(path);
416 }
417
418 async tags(repo: string): Promise<string[]> {
419 return (await this.gh.pages<string>(`repos/${repo}/tags`, '[.[].name]')).flat();
420 }
421
422 // compare lists oldest first and caps at 250; the ranges asked here are far smaller
423 async compare(repo: string, base: string, head: string): Promise<Commit[]> {
424 const cmp = await this.gh.json<{ commits: GhCommit[] }>(`repos/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
425 return cmp.commits.map(commit).reverse();
426 }
427
428 compareDiff(repo: string, base: string, head: string): Promise<string> {
429 return this.gh.text(`repos/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, 'application/vnd.github.diff');
430 }
431
432 async commits(repo: string, ref: string): Promise<Commit[]> {
433 return (await this.gh.pages<GhCommit>(`repos/${repo}/commits?sha=${encodeURIComponent(ref)}`, COMMIT_JQ)).map(commit);
434 }
435
436 file(repo: string, path: string, ref?: string): Promise<string | undefined> {
437 return this.gh.text(`repos/${repo}/contents/${path}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`, RAW).catch(() => undefined);
438 }
439
440 // the recursive git tree in one call; github truncates it past its own limit and says so
441 async contents(repo: string, ref?: string): Promise<TreeFile[]> {
442 const at = ref ?? (await this.defaultBranch(repo));
443 const tree = await this.gh.json<{ tree?: { path: string; type: string; sha: string }[] }>(`repos/${repo}/git/trees/${encodeURIComponent(at)}?recursive=1`);
444 return (tree.tree ?? []).filter((e) => e.type === 'blob').map((e) => ({ path: e.path, id: e.sha }));
445 }
446
447 // one conditional probe on the newest item, then the pages since the stamp only when it moved
448 async items(repo: string, since: string, token?: string): Promise<Conditional<WatchItem[]>> {
449 const probe = await this.gh.api(`repos/${repo}/issues?state=all&sort=updated&direction=desc&per_page=1`, { etag: token });
450 if (probe.status === 304) return { changed: false, rate: rate(probe) };
451 const raw = await this.gh.pages<RawItem>(`repos/${repo}/issues?state=all&sort=updated&direction=asc&since=${since}`, ITEM_JQ);
452 return { changed: true, token: probe.etag, rate: rate(probe), value: raw.map(toWatchItem) };
453 }
454
455 async runs(repo: string, token?: string): Promise<Conditional<Run[]>> {
456 let probe: ApiResponse;
457 try {
458 probe = await this.gh.api(`repos/${repo}/actions/runs?per_page=30`, { etag: token });
459 } catch (error) {
460 // a repo without actions answers 404 or 403
461 if (missing(error)) return { changed: false, rate: {} };
462 throw error;
463 }
464 if (probe.status === 304) return { changed: false, rate: rate(probe) };
465 const parsed = JSON.parse(probe.body || '{}') as { workflow_runs?: GhRun[] };
466 return { changed: true, token: probe.etag, rate: rate(probe), value: await this.toRuns(repo, parsed.workflow_runs ?? []) };
467 }
468
469 async branchRuns(repo: string, branch: string): Promise<Run[]> {
470 try {
471 const parsed = await this.gh.json<{ workflow_runs?: GhRun[] } | null>(`repos/${repo}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=30`);
472 return await this.toRuns(repo, parsed?.workflow_runs ?? []);
473 } catch (error) {
474 if (missing(error)) return [];
475 throw error;
476 }
477 }
478
479 async run(repo: string, id: string): Promise<Run> {
480 const r = await this.gh.json<GhRun>(`repos/${repo}/actions/runs/${encodeURIComponent(id)}`);
481 return run(r, await this.isTag(repo, r));
482 }
483
484 async pulls(repo: string, token?: string): Promise<Conditional<PullHead[]>> {
485 const probe = await this.gh.api(`repos/${repo}/pulls?state=open&per_page=100`, { etag: token });
486 if (probe.status === 304) return { changed: false, rate: rate(probe) };
487 const raw = JSON.parse(probe.body || '[]') as { number: number; title: string; head: { ref: string; sha: string }; html_url: string; user: GhUser }[];
488 return { changed: true, token: probe.etag, rate: rate(probe), value: raw.map((p) => ({ number: p.number, title: p.title, branch: p.head.ref, sha: p.head.sha, url: p.html_url, user: p.user.login })) };
489 }
490
491 logCommand(repo: string, run: string): string {
492 return `gh run view ${run} --log-failed -R ${repo}`;
493 }
494
495 parseUrl(url: string): ForgeLink | undefined {
496 const m = GH_URL.exec(url.trim()) ?? GH_API_URL.exec(url.trim());
497 if (!m) return undefined;
498 return { repo: m[1]!, kind: m[2] === 'issues' ? 'issue' : 'pr', number: Number(m[3]) };
499 }
500
501 // every write names its repository in the api path, so neither the working directory nor a fork's upstream decides it
502 async post(repo: string, post: ForgePost): Promise<string> {
503 const url = async (path: string, method: string, input: Record<string, unknown>) => (await this.gh.json<{ html_url: string }>(`repos/${repo}/${path}`, { method, input })).html_url;
504 switch (post.action) {
505 case 'create':
506 if (post.kind === 'issue') return url('issues', 'POST', { title: post.title, body: post.body });
507 if (post.kind === 'pr') return url('pulls', 'POST', { title: post.title, body: post.body, base: post.base, head: post.head, draft: post.draft ?? false });
508 return url('releases', 'POST', { tag_name: post.tag, body: post.body, draft: post.draft ?? false, prerelease: post.prerelease ?? false, ...given({ target_commitish: post.target, name: post.title }) });
509 case 'comment':
510 return url(`issues/${post.number}/comments`, 'POST', { body: post.body });
511 case 'edit': {
512 if (post.kind !== 'release') return url(`${post.kind === 'issue' ? 'issues' : 'pulls'}/${post.number}`, 'PATCH', given({ title: post.title, body: post.body }));
513 const release = await this.gh.json<{ id: number }>(`repos/${repo}/releases/tags/${encodeURIComponent(post.tag)}`);
514 return url(`releases/${release.id}`, 'PATCH', given({ name: post.title, body: post.body }));
515 }
516 case 'review':
517 return url(`pulls/${post.number}/reviews`, 'POST', { event: VERDICTS[post.verdict], ...given({ body: post.body }) });
518 case 'merge':
519 await this.gh.json(`repos/${repo}/pulls/${post.number}/merge`, { method: 'PUT', input: { merge_method: post.method, ...given({ commit_title: post.title, commit_message: post.body }) } });
520 return (await this.pull(repo, post.number)).url;
521 }
522 }
523
524 writeOf(words: string[]): ForgeWrite | undefined {
525 return ghWriteOf(words);
526 }
527
528 cliText(write: ForgeWrite): CliText {
529 return ghCliText(write);
530 }
531}
532src/grade.ts 178 lines1import type { Forge } from './forge/forge.ts';
2import type { Git } from './forge/git.ts';
3import type { Judge } from './judge/types.ts';
4import { indexTree, treeSubject } from './locate/tree.ts';
5import { REMOVED_PACKS } from './packs/builtin.ts';
6import { runParts } from './packs/run.ts';
7import { parseSubject, refusal, type ParsedKind } from './packs/subject.ts';
8import type { Pack, Report, Subject } from './packs/types.ts';
9import type { Checkout, CheckoutFs, Checkouts } from './repo/checkout.ts';
10import { defaultTarget, type RepoConfig } from './repo/config.ts';
11import { localSource, remoteSource } from './repo/source.ts';
12import { commitSubject, issueSubject, planSubject, prRangeSubject, prSubject, releaseSubject, rulesSubjects, textRulesSubjects, textSubject } from './repo/subjects.ts';
13import { checkoutSource, forgeSource, type Discoveries, type RuleSource } from './rules/discover.ts';
14import { truncate } from './tokens.ts';
15
16export type GradeOptions = {
17 repo?: string;
18 // the directory whose checkout the grade reads, absolute; the calling agent's or the session's when unset
19 cwd?: string;
20 text?: string;
21 ref?: string;
22 top?: number;
23};
24
25export type GradeHost = {
26 forge: Forge;
27 judge: Judge;
28 // the rule discoveries in flight, shared by every grade and post of the session
29 discoveries: Discoveries;
30 fs: CheckoutFs;
31 checkouts: Checkouts;
32};
33
34// what one grade reads: the checkout it is bound to, and whether the caller named it
35export type GradeScope = { checkout: Checkout; named: boolean };
36
37// subject is the one graded, its opening when it was graded in parts
38export type Graded = { report: Report; subject: Subject };
39
40export async function grade(host: GradeHost, scope: GradeScope, packName: string, ref: string, opts: GradeOptions = {}): Promise<Graded> {
41 const pack = scope.checkout.packs[packName];
42 if (!pack) {
43 const removed = REMOVED_PACKS[packName];
44 if (removed) throw new Error(removed(host.forge, opts.repo ?? scope.checkout.repo ?? '<owner/name>'));
45 throw new Error(`unknown pack ${packName} (have: ${Object.keys(scope.checkout.packs).join(', ')})`);
46 }
47 const { subjects, config } = await subjectFor(host, scope, pack, ref, opts);
48 const report = await runParts(pack, subjects, host.judge, config, { top: opts.top });
49 return { report, subject: subjects[0]! };
50}
51
52// the subject a pack grades, one per part when it is too long to judge at once, and the conventions of the repository it is in;
53// a refusal names the pack by the name it was asked for
54export async function subjectFor(host: GradeHost, scope: GradeScope, pack: Pick<Pack, 'name' | 'subject'>, ref: string, opts: GradeOptions = {}): Promise<{ subjects: Subject[]; config: RepoConfig }> {
55 const { forge } = host;
56 const { checkout } = scope;
57 const repo = opts.repo ?? checkout.repo;
58 const needRepo = () => {
59 if (!repo) throw new Error(`no repository: pass repo as the ${forge.name} path or run inside a checkout with a ${forge.name} remote`);
60 return repo;
61 };
62 // a subject in the checkout's repository takes the checkout's conventions; any other, that repository's from the forge
63 const configOf = async (at: string | undefined): Promise<RepoConfig> => (at === undefined || at === checkout.repo ? checkout.config : host.checkouts.remoteConfig(forge, at));
64 // the subjects that read the checkout refuse a repo that is not the checkout's, rather than mix the two
65 const local = (what: string): { git: Git; root: string } => {
66 if (opts.repo !== undefined && opts.repo !== checkout.repo) {
67 throw new Error(`${what} reads the checkout at ${checkout.root}, which is ${checkout.repo ?? 'not a checkout of any repository'}, but repo is ${opts.repo}: pass the cwd of a checkout of ${opts.repo}, or drop repo`);
68 }
69 if (!checkout.git) throw new Error(`${what} reads the checkout, and ${checkout.root} is not in a git checkout: pass cwd`);
70 return { git: checkout.git, root: checkout.root };
71 };
72 // release and rules read the checkout when it serves the repo; a named cwd always does, else another repo comes from the forge
73 const readsCheckout = () => checkout.git !== undefined && (scope.named || opts.repo === undefined || opts.repo === checkout.repo);
74 // the subject is parsed, and a bad one refused, before any forge request; a url names its own repo
75 const parsed = <K extends ParsedKind>(kind: K) => parseSubject({ pack: pack.name, kind }, ref, forge, opts.repo);
76 switch (pack.subject) {
77 case 'issue': {
78 const p = parsed('issue');
79 const at = p.repo ?? needRepo();
80 const config = await configOf(at);
81 return { subjects: [await issueSubject(forge, at, p.number, config, { pack: pack.name, kind: 'issue' })], config };
82 }
83 case 'pr': {
84 const p = parsed('pr');
85 // base..head in the checkout is the pr the range would open, graded before it exists
86 if ('range' in p) {
87 const { git } = local('a pr range');
88 return { subjects: [await prRangeSubject(git, p.range, checkout.config, checkout.repo ? { forge, repo: checkout.repo } : undefined)], config: checkout.config };
89 }
90 const at = p.repo ?? needRepo();
91 const config = await configOf(at);
92 return { subjects: [await prSubject(forge, at, p.number, config)], config };
93 }
94 case 'commit': {
95 const p = parsed('commit');
96 const { git } = local('a commit');
97 return { subjects: [await commitSubject(git, p.ref, checkout.config)], config: checkout.config };
98 }
99 case 'release': {
100 const p = parsed('release');
101 let subject: Subject;
102 let config: RepoConfig;
103 if (readsCheckout()) {
104 const { git, root } = local('a release');
105 config = checkout.config;
106 // the working tree stands in for HEAD, read under the checkout's root
107 subject = await releaseSubject(localSource(git, opts.ref ?? 'HEAD', (f) => host.fs.read(`${root}/${f}`), (f) => host.fs.exists(`${root}/${f}`)), config);
108 } else {
109 const at = needRepo();
110 config = await configOf(at);
111 subject = await releaseSubject(remoteSource(forge, at, opts.ref ?? defaultTarget(config) ?? (await forge.defaultBranch(at))), config);
112 }
113 if (p.proposed !== undefined) subject.facts['proposed'] = p.proposed;
114 return { subjects: [subject], config };
115 }
116 case 'rules': {
117 // an issue by number or url, or free text: text when given, else the subject itself
118 const p = parsed('rules').subject;
119 let at: string | undefined;
120 if (readsCheckout()) {
121 local('rules');
122 at = checkout.repo;
123 } else at = needRepo();
124 const config = await configOf(at);
125 const rules = { forge, repo, source: ruleSource(host, scope, at), discoveries: host.discoveries };
126 // free text is read the way the outbound gate reads it, every part of a long text judged
127 if (p.kind === 'text') return { subjects: await textRulesSubjects(rules, { text: opts.text ?? p.text }, config), config };
128 return { subjects: await rulesSubjects({ ...rules, repo: p.repo ?? needRepo() }, { number: p.number, pack: pack.name }, config), config };
129 }
130 case 'tree': {
131 // text is the subject when given; otherwise an issue or pull request is its title and body, anything else the text itself
132 const p = opts.text === undefined ? parsed('mixed').subject : { kind: 'text' as const, text: opts.text };
133 const { git, root } = local('locate');
134 let text: string;
135 let label: string;
136 if (p.kind === 'text' || p.kind === 'commit') {
137 text = p.kind === 'text' ? p.text : p.ref;
138 label = truncate(text, 40);
139 } else {
140 const at = p.repo ?? needRepo();
141 const item = p.kind === 'issue' ? await forge.issue(at, p.number) : await forge.pull(at, p.number);
142 if (p.kind === 'issue' && item.pr) throw refusal({ pack: pack.name, kind: 'mixed' }, `#${p.number}`, `subject is ${at}#${p.number}, a pull request, not an issue`, forge);
143 text = `${item.title}\n\n${item.body}`;
144 label = `${at}#${p.number}`;
145 }
146 // the tree is the checkout's, whichever repository the issue is in
147 const index = await indexTree({
148 list: async () => (await git(['ls-files', '-z'])).split('\0'),
149 size: async (f) => (await host.fs.stat(`${root}/${f}`)).size,
150 read: (f) => host.fs.read(`${root}/${f}`),
151 });
152 return { subjects: [treeSubject(text, label, index)], config: checkout.config };
153 }
154 case 'plan': {
155 if (opts.text === undefined) throw new Error(`${pack.name} pack: no plan; the pack reads the plan from text: grade(pack: "${pack.name}", subject: "<issue number>", text: "<plan>")`);
156 const p = parsed('issue');
157 const at = p.repo ?? needRepo();
158 return { subjects: [await planSubject(forge, at, p.number, opts.text, pack.name)], config: await configOf(at) };
159 }
160 default:
161 return { subjects: [textSubject(opts.text ?? ref)], config: checkout.config };
162 }
163}
164
165// the checkout serves its own repo; any other repo, or no checkout at all, is read from the forge
166export function ruleSource(host: Pick<GradeHost, 'forge' | 'fs'>, scope: GradeScope, repo: string | undefined): RuleSource {
167 const { checkout } = scope;
168 if (checkout.git && (repo === undefined || repo === checkout.repo)) return checkoutSource(checkout.root, checkout.git, host.fs, host.forge);
169 if (!repo) throw new Error(`no repository: pass repo as the ${host.forge.name} path or run inside a checkout with a ${host.forge.name} remote`);
170 return forgeSource(host.forge, repo);
171}
172
173// a named cwd, else the calling subagent's spawn directory, else the session's checkout
174export async function scopeOf(checkouts: Pick<Checkouts, 'resolve'>, session: () => Promise<Checkout>, cwd: string | undefined, spawnDir: string | undefined): Promise<GradeScope> {
175 if (cwd !== undefined) return { checkout: await checkouts.resolve(cwd), named: true };
176 return { checkout: spawnDir === undefined ? await session() : await checkouts.resolve(spawnDir), named: false };
177}
178src/repo/checkout.ts 84 lines1import type { Forge } from '../forge/forge.ts';
2import { localGit, type Git } from '../forge/git.ts';
3import { loadPacks, type FsLike } from '../packs/load.ts';
4import type { Pack } from '../packs/types.ts';
5import type { RunLike } from '../process.ts';
6import { CONFIG_PATH, PACKS_DIR, readConfig, resolveConfig, type RepoConfig } from './config.ts';
7
8export type CheckoutFs = FsLike & { stat: (path: string) => Promise<{ size: number; mtimeMs?: number }> };
9
10// one directory's repository: where it is, what it is on the forge, and the conventions and packs it defines
11export type Checkout = {
12 // the git toplevel of the directory (the worktree itself, not its main tree), or the directory when it is not in git
13 root: string;
14 // git in root; undefined when the directory is not in a git checkout
15 git?: Git;
16 // the forge repository root is a checkout of, when it has a remote on the forge
17 repo?: string;
18 defaultBranch?: string;
19 config: RepoConfig;
20 packs: Record<string, Pack>;
21};
22
23export type CheckoutHost = {
24 run: RunLike;
25 fs: CheckoutFs;
26 // a forge whose checkout() answers for the directory
27 forgeAt: (dir: string) => Forge;
28 // the config layer under every repository's own file: the config option, or else the global file
29 base: () => unknown;
30};
31
32type Entry = { repo?: string; defaultBranch?: string; stamp: string; config: RepoConfig; packs: Record<string, Pack> };
33
34// resolves a directory to its checkout, caching the forge lookup by root and the conventions until .sift/ changes
35export class Checkouts {
36 private readonly cache = new Map<string, Entry>();
37
38 constructor(private readonly host: CheckoutHost) {}
39
40 async resolve(dir: string): Promise<Checkout> {
41 if (!dir.startsWith('/')) throw new Error(`cwd must be an absolute path, got ${dir}`);
42 if (!(await this.host.fs.exists(dir))) throw new Error(`cwd ${dir} does not exist`);
43 const top = await this.host.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 30_000 });
44 const inGit = top.exitCode === 0 && top.stdout.trim() !== '';
45 const root = inGit ? top.stdout.trim() : dir;
46 const git = inGit ? localGit(this.host.run, async () => root) : undefined;
47 const stamp = await this.stamp(root);
48 let entry = this.cache.get(root);
49 if (!entry) {
50 const found = await this.host.forgeAt(root).checkout();
51 entry = { repo: found?.repo, defaultBranch: found?.defaultBranch, ...(await this.conventions(root, found?.defaultBranch)), stamp };
52 } else if (entry.stamp !== stamp) {
53 entry = { ...entry, ...(await this.conventions(root, entry.defaultBranch)), stamp };
54 }
55 this.cache.set(root, entry);
56 return { root, git, repo: entry.repo, defaultBranch: entry.defaultBranch, config: entry.config, packs: entry.packs };
57 }
58
59 // a repository's conventions read through the forge, for a subject in a repository no checkout here serves
60 async remoteConfig(forge: Forge, repo: string): Promise<RepoConfig> {
61 const defaultBranch = await forge.defaultBranch(repo);
62 const raw = await forge.file(repo, CONFIG_PATH, defaultBranch);
63 return resolveConfig([this.host.base(), raw === undefined ? undefined : readConfig(raw, `${repo}:${CONFIG_PATH}`)], defaultBranch);
64 }
65
66 private async conventions(root: string, defaultBranch: string | undefined): Promise<Pick<Entry, 'config' | 'packs'>> {
67 const path = `${root}/${CONFIG_PATH}`;
68 const own = (await this.host.fs.exists(path)) ? readConfig(await this.host.fs.read(path), path) : undefined;
69 return { config: resolveConfig([this.host.base(), own], defaultBranch), packs: await loadPacks(this.host.fs, root) };
70 }
71
72 // what the conventions were read from: the config file and every pack file, by modification time
73 private async stamp(root: string): Promise<string> {
74 const { fs } = this.host;
75 const mtime = async (p: string) => ((await fs.exists(p)) ? String((await fs.stat(p)).mtimeMs ?? '') : '-');
76 const parts = [await mtime(`${root}/${CONFIG_PATH}`)];
77 const dir = `${root}/${PACKS_DIR}`;
78 if (await fs.exists(dir)) {
79 for (const e of await fs.list(dir)) parts.push(`${e.name}:${await mtime(`${dir}/${e.name}`)}`);
80 }
81 return parts.join('|');
82 }
83}
84src/repo/config.ts 227 lines1import { BUMPS, CONVENTIONAL_BUMPS, CONVENTIONAL_FORMAT, type Bump } from './commits.ts';
2import { CALVER_PATTERN, SEMVER_PATTERN } from './version.ts';
3import type { Channel } from '../gate/channels.ts';
4import { isTextKind, TEXT_KIND_NAMES } from '../rules/kinds.ts';
5
6// presets expand to a regex at resolve time; an explicit pattern beside one wins
7export const COMMIT_FORMATS = { conventional: CONVENTIONAL_FORMAT } as const;
8export const COMMIT_BUMPS: Record<keyof typeof COMMIT_FORMATS, Record<string, Bump>> = { conventional: CONVENTIONAL_BUMPS };
9// matched against type(scope), or the bare type without a scope
10export const SCOPE_PATTERNS = { issue: String.raw`^(chore\(.*\)|[^(]+(\(#\d+\))?)$`, none: String.raw`^[^(]*$` } as const;
11export const VERSION_PATTERNS = { semver: SEMVER_PATTERN, calver: CALVER_PATTERN } as const;
12
13export type RepoConfig = {
14 commits: {
15 // preset for format: conventional is the conventional commits header, none runs no format check
16 convention: keyof typeof COMMIT_FORMATS | 'none';
17 // regex over the subject line with the named groups type, scope, breaking and description
18 format?: string;
19 types: string[];
20 // the release bump each type calls for; a type not listed calls for none, and a breaking change is a breaking
21 // bump whatever its type. unset, the convention's preset fills it (conventional: feat minor, fix and perf patch)
22 bumps?: Record<string, Bump>;
23 // preset for scopePattern: issue requires #<n> (chore excepted), any accepts anything, none forbids a scope
24 scope: keyof typeof SCOPE_PATTERNS | 'any';
25 // regex over the header's type(scope), or the bare type when there is no scope
26 scopePattern?: string;
27 forbidTrailers: string[];
28 };
29 branches: {
30 protected: string[];
31 // regex for work branches, e.g. ^(feat|fix|chore)/\d+$
32 pattern?: string;
33 };
34 issues: {
35 // each inner list is a group, one label from each group is required
36 requiredLabelGroups: string[][];
37 milestone: boolean;
38 templateSections: string[];
39 // labels whose issues must be a sub-issue of a parent
40 childLabels: string[];
41 };
42 prs: {
43 linkIssue: boolean;
44 // branches a PR may target, as a list of names or a regex; target: "dev" from older configs reads as targets: ["dev"]
45 targets?: string[] | string;
46 templateSections: string[];
47 };
48 rules: {
49 // documents used without being judged as rule documents: paths in the checkout, or owner/repo:path[@ref] read from the forge
50 docs: string[];
51 // paths or globs (* within a segment, ** across) never considered as rule documents
52 exclude: string[];
53 // rules past this count are dropped and rules.present says so
54 maxRules: number;
55 };
56 release: {
57 // preset for versionPattern; with neither set no version is computed or checked
58 scheme?: keyof typeof VERSION_PATTERNS;
59 // regex over a version: its numeric named groups order it, major, minor and patch (when named) bump it
60 versionPattern?: string;
61 // regex over a tag with a version group; by default tagPrefix followed by the version
62 tagPattern?: string;
63 // unset: no changelog is read or checked
64 changelog?: string;
65 tagPrefix: string;
66 // what a breaking change requires below 1.0.0
67 zeroVerBreaking: 'major' | 'minor';
68 // manifests whose changes require a bump on their own: keys are regexes over dotted paths in a toml, json or yaml
69 // file (arrays indexed numerically), pattern a regex over the text of any file whose matched text must not change
70 manifests: { path: string; keys?: string[]; pattern?: string; bump: 'major' | 'minor' | 'patch' }[];
71 };
72 outbound: {
73 // channels added to the default table, or replacing a default entry of the same name
74 channels: Channel[];
75 };
76};
77
78export const DEFAULT_CONFIG: RepoConfig = {
79 commits: {
80 convention: 'none',
81 types: ['feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'style', 'ci', 'perf', 'build', 'revert'],
82 scope: 'any',
83 forbidTrailers: [],
84 },
85 branches: { protected: [] },
86 issues: { requiredLabelGroups: [], milestone: false, templateSections: [], childLabels: [] },
87 prs: { linkIssue: false, templateSections: [] },
88 rules: { docs: [], exclude: [], maxRules: 200 },
89 release: { tagPrefix: 'v', zeroVerBreaking: 'minor', manifests: [] },
90 outbound: { channels: [] },
91};
92
93export const CONFIG_PATH = '.sift/config.json';
94export const PACKS_DIR = '.sift/packs';
95
96function merge<T extends Record<string, unknown>>(base: T, over: Partial<T> | undefined): T {
97 if (!over) return base;
98 const out = { ...base } as Record<string, unknown>;
99 for (const [k, v] of Object.entries(over)) {
100 const current = out[k];
101 if (v && typeof v === 'object' && !Array.isArray(v) && current && typeof current === 'object' && !Array.isArray(current)) {
102 out[k] = merge(current as Record<string, unknown>, v as Record<string, unknown>);
103 } else if (v !== undefined) {
104 out[k] = v;
105 }
106 }
107 return out as T;
108}
109
110// layers apply in order, each field by field over the last; presets expand once the layers are merged
111export function resolveConfig(layers: unknown | unknown[], defaultBranch?: string): RepoConfig {
112 let config = DEFAULT_CONFIG;
113 for (const raw of Array.isArray(layers) ? layers : [layers]) {
114 if (raw && typeof raw === 'object') config = merge(config, raw as Partial<RepoConfig>);
115 }
116 if (config.branches.protected.length === 0 && defaultBranch) {
117 config.branches = { ...config.branches, protected: [defaultBranch] };
118 }
119 return expandPresets(config);
120}
121
122function expandPresets(config: RepoConfig): RepoConfig {
123 const commits = { ...config.commits };
124 // commits parse with the conventional header when no format is set at all, so the bumps follow the same fallback
125 if (commits.bumps === undefined) commits.bumps = commits.convention !== 'none' ? COMMIT_BUMPS[commits.convention] : commits.format === undefined ? CONVENTIONAL_BUMPS : {};
126 for (const [type, bump] of Object.entries(commits.bumps)) {
127 if (!BUMPS.includes(bump)) throw new Error(`sift config: commits.bumps.${type} is ${JSON.stringify(bump)}, not one of ${BUMPS.join(', ')}`);
128 }
129 if (commits.format === undefined && commits.convention !== 'none') commits.format = COMMIT_FORMATS[commits.convention];
130 if (commits.scopePattern === undefined && commits.scope !== 'any') commits.scopePattern = SCOPE_PATTERNS[commits.scope];
131 const { target, ...prs } = config.prs as RepoConfig['prs'] & { target?: string };
132 if (prs.targets === undefined && target !== undefined) prs.targets = [target];
133 const release = { ...config.release };
134 if (release.versionPattern === undefined && release.scheme !== undefined) release.versionPattern = VERSION_PATTERNS[release.scheme];
135 if (release.tagPattern === undefined) release.tagPattern = tagPatternFor(release.tagPrefix);
136 return { ...config, commits, prs, release };
137}
138
139// a config file's text, parsed with every regex field in it compiled, so a bad one is refused where the file is read
140// rather than by the first grade that reaches it. from names the file in the error
141export function readConfig(text: string, from: string): unknown {
142 let raw: unknown;
143 try {
144 raw = JSON.parse(text);
145 } catch (e) {
146 throw new Error(`sift config ${from}: ${(e as Error).message}`);
147 }
148 for (const [field, pattern] of patternFields(raw)) {
149 try {
150 new RegExp(pattern);
151 } catch (e) {
152 throw new Error(`sift config ${from}: ${field} is not a valid regex: ${(e as Error).message}`);
153 }
154 }
155 listOf(fieldsOf(fieldsOf(raw).outbound).channels).forEach((c, i) => {
156 const channel = fieldsOf(c);
157 if (channel.textKind !== undefined && !isTextKind(channel.textKind)) {
158 throw new Error(`sift config ${from}: outbound.channels[${entry(channel.name, i)}].textKind is ${JSON.stringify(channel.textKind)}, not one of ${TEXT_KIND_NAMES.join(', ')}`);
159 }
160 });
161 return raw;
162}
163
164const fieldsOf = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
165const listOf = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
166// an entry of a list by its name when it has one, else by its index
167const entry = (name: unknown, i: number) => (typeof name === 'string' && name !== '' ? name : String(i));
168
169// every field of one layer that holds a regex, by its dotted name, where the layer sets it to a string
170function patternFields(raw: unknown): [string, string][] {
171 const layer = fieldsOf(raw);
172 const commits = fieldsOf(layer.commits);
173 const branches = fieldsOf(layer.branches);
174 const prs = fieldsOf(layer.prs);
175 const release = fieldsOf(layer.release);
176 const outbound = fieldsOf(layer.outbound);
177 const fields: [string, unknown][] = [
178 ['commits.format', commits.format],
179 ['commits.scopePattern', commits.scopePattern],
180 ['branches.pattern', branches.pattern],
181 ['prs.targets', prs.targets],
182 ['release.versionPattern', release.versionPattern],
183 ['release.tagPattern', release.tagPattern],
184 ...listOf(release.manifests).flatMap((m, i): [string, unknown][] => {
185 const manifest = fieldsOf(m);
186 const at = `release.manifests[${entry(manifest.path, i)}]`;
187 return [...listOf(manifest.keys).map((k, j): [string, unknown] => [`${at}.keys[${j}]`, k]), [`${at}.pattern`, manifest.pattern]];
188 }),
189 ...listOf(outbound.channels).flatMap((c, i): [string, unknown][] => {
190 const channel = fieldsOf(c);
191 const at = `outbound.channels[${entry(channel.name, i)}]`;
192 return [[`${at}.tool`, channel.tool], [`${at}.text.command`, fieldsOf(channel.text).command]];
193 }),
194 ];
195 return fields.filter((f): f is [string, string] => typeof f[1] === 'string');
196}
197
198// the tag pattern a prefix stands for: the prefix, then the version
199export function tagPatternFor(prefix: string): string {
200 return `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?<version>.+)$`;
201}
202
203// the branch a release is cut from when none is named: the first listed target, none when targets is a regex
204export function defaultTarget(config: RepoConfig): string | undefined {
205 return Array.isArray(config.prs.targets) ? config.prs.targets[0] : undefined;
206}
207
208// the global file: $XDG_CONFIG_HOME/sift/config.json, else ~/.config/sift/config.json
209export function globalConfigPath(env: { XDG_CONFIG_HOME?: string | null; HOME?: string | null }): string | undefined {
210 const base = env.XDG_CONFIG_HOME || (env.HOME ? `${env.HOME}/.config` : undefined);
211 return base ? `${base}/sift/config.json` : undefined;
212}
213
214export type ConfigSources = {
215 // the config option, parsed; set, it replaces the global file
216 option?: unknown;
217 // the global file, parsed, when it exists
218 global?: unknown;
219 // the repository's .sift/config.json, parsed, when it exists
220 repo?: unknown;
221};
222
223// layers in application order: defaults, then the option or the global file, then the repository file
224export function configLayers(sources: ConfigSources): unknown[] {
225 return [sources.option !== undefined ? sources.option : sources.global, sources.repo];
226}
227src/judge/index.ts 152 lines1import { JEV_DEFAULTS, JevJudge, type FetchLike } from './jev.ts';
2import { ModelJudge, type CompleteLike } from './model.ts';
3import { labelOf } from './bands.ts';
4import { failureText, keyFix, keyRefText, shadowedText, type Judge, type Judgement, type KeyOrigin, type KeySource, type Questions } from './types.ts';
5
6export type Backend = 'auto' | 'jev' | 'model' | 'off';
7
8export type JudgeConfig = {
9 backend: Backend;
10 apiKey?: string;
11 keyOrigin?: KeyOrigin;
12 jevModel: string;
13 jevBaseUrl: string;
14 fallbackModel: string;
15};
16
17export const JUDGE_DEFAULTS: JudgeConfig = {
18 backend: 'auto',
19 jevModel: JEV_DEFAULTS.model,
20 jevBaseUrl: JEV_DEFAULTS.baseUrl,
21 fallbackModel: 'haiku',
22};
23
24// the key itself stays here; origin is what may be shown, its source, its last four characters and the other sources
25export type ApiKey = { key: string; origin: KeyOrigin };
26
27const ending = (key: string) => key.slice(-4);
28
29// the credentials file of the session's config dir, where the engine keeps the apiKey option
30export function credentialsPath(configDir: string | undefined): string {
31 const dir = configDir?.replace(/\/+$/, '');
32 return dir ? `${dir}/.credentials.json` : '~/.claude/.credentials.json';
33}
34
35// the jev key and where it came from: the apiKey option, then the environment, then the settings env block.
36// every other source holding a key is named by its last four characters, so a shadowed key is visible.
37// configDir is CLAUDE_CONFIG_DIR, which names the credentials file the option is stored in
38export async function resolveApiKey(sources: {
39 option?: string;
40 env: () => Promise<string | undefined>;
41 settings: () => Promise<Record<string, unknown>>;
42 configDir: () => Promise<string | undefined>;
43}): Promise<ApiKey | undefined> {
44 const fromSettings = ((await sources.settings())['env'] as Record<string, unknown> | undefined)?.['TYPESAFE_API_KEY'];
45 const held: { source: KeySource; key: string | undefined }[] = [
46 { source: 'option', key: sources.option },
47 { source: 'env', key: await sources.env() },
48 { source: 'settings', key: typeof fromSettings === 'string' ? fromSettings : undefined },
49 ];
50 const [chosen, ...rest] = held.filter((h): h is { source: KeySource; key: string } => !!h.key);
51 if (!chosen) return undefined;
52 const others = rest.map((o) => ({ source: o.source, ending: ending(o.key), same: o.key === chosen.key }));
53 const origin: KeyOrigin = { source: chosen.source, ending: ending(chosen.key), others };
54 if (chosen.source === 'option') origin.stored = credentialsPath(await sources.configDir());
55 return { key: chosen.key, origin };
56}
57
58// the backend in one line; for jev where its key came from, its last four characters and any shadowed key, never a key.
59// a rejected key says so and names the fix
60export function judgeLine(backend: string, origin?: KeyOrigin, rejected = false): string {
61 if (backend !== 'jev' || !origin) return `judge: ${backend}`;
62 const head = rejected
63 ? [`judge: jev, key rejected from ${keyRefText(origin)}`, `fix: ${keyFix(origin)}`]
64 : [`judge: jev, key from ${keyRefText(origin)}`];
65 return [...head, ...shadowedText(origin)].join('; ');
66}
67
68export class DisabledJudge implements Judge {
69 readonly name = 'off';
70 async ask(): Promise<Judgement> {
71 return { ok: false, reason: 'disabled', message: 'judge backend is off', backend: this.name };
72 }
73}
74
75export type JudgeHost = {
76 fetch: FetchLike;
77 complete: CompleteLike;
78 now?: () => number;
79};
80
81export function makeJudge(config: JudgeConfig, host: JudgeHost): Judge {
82 const wantJev = config.backend === 'jev' || (config.backend === 'auto' && !!config.apiKey);
83 if (config.backend === 'off') return new DisabledJudge();
84 if (wantJev) {
85 if (!config.apiKey) return new DisabledJudge();
86 return new JevJudge(
87 { apiKey: config.apiKey, model: config.jevModel, baseUrl: config.jevBaseUrl, keyOrigin: config.keyOrigin },
88 host.fetch,
89 host.now,
90 );
91 }
92 return new ModelJudge(config.fallbackModel, host.complete, host.now);
93}
94
95export type Decision = {
96 at: number;
97 module: string;
98 backend: string;
99 ok: boolean;
100 latencyMs?: number;
101 reason?: string;
102 digest: string;
103 answers?: Record<string, string>;
104 action: string;
105 shadow: boolean;
106 // the session that made the decision; the ring is shared by every session running the plugin
107 session?: string;
108 // what a judge call cost, and what a prune took out of the context
109 requestTokens?: number;
110 responseTokens?: number;
111 tokensRemoved?: number;
112 // an outbound write let through over its ruling: the reason given, and the text written
113 override?: string;
114 text?: string;
115};
116
117// a judge that records every call for the /sift report and calibration
118export class LoggedJudge implements Judge {
119 readonly name: string;
120 constructor(
121 private readonly inner: Judge,
122 private readonly record: (d: Omit<Decision, 'action' | 'shadow' | 'module'>) => void,
123 ) {
124 this.name = inner.name;
125 }
126 get keyRejected(): boolean {
127 return this.inner.keyRejected ?? false;
128 }
129 async ask(state: unknown, questions: Questions): Promise<Judgement> {
130 const result = await this.inner.ask(state, questions);
131 this.record({
132 at: Date.now(),
133 backend: result.backend,
134 ok: result.ok,
135 latencyMs: result.ok ? result.latencyMs : undefined,
136 reason: result.ok ? undefined : failureText(result),
137 requestTokens: result.usage?.requestTokens,
138 responseTokens: result.usage?.responseTokens,
139 digest: digestOf(state),
140 answers: result.ok
141 ? Object.fromEntries(Object.entries(result.answers).map(([k, a]) => [k, labelOf(a)]))
142 : undefined,
143 });
144 return result;
145 }
146}
147
148export function digestOf(state: unknown): string {
149 const text = typeof state === 'string' ? state : JSON.stringify(state);
150 return text.length > 80 ? `${text.slice(0, 77)}...` : text;
151}
152