Public: anyone can read this; the crew can change it.
A standing rule per repository: decisions here go to me, the decider, or the decider telling me the unsure ones #1754
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1605-a-standing-rule-per-repository-decisions → main 10 commits fork 9 threads · 9 unresolved
README.md +18
| @@ -282,6 +282,24 @@ configured leaves it with you. Undo for a day (`/api/v1/asks/decide/<id>/undo`, | ||
| 282 | 282 | `asks_undecide`, `U`). Each tap is a paid call, so one card is handed over |
| 283 | 283 | once in ten minutes. |
| 284 | 284 | |
| 285 | **The standing rule** says who answers a repository's hand-overs without a | |
| 286 | tap, three ways: `me` (the default: they wait for you), `decider` (the sweep | |
| 287 | asks the decider about each parked card and its word stands, whatever its | |
| 288 | confidence), or `decider_tell` (the decider's sure set-asides, 70% or more, | |
| 289 | take the card off your board; the unsure ones stay with you). The tap on the | |
| 290 | card follows the same rule. A shelved repository, one whose autonomy is `off`, | |
| 291 | has its parked cards closed by rule with no model call whenever the rule is | |
| 292 | not `me`, so turning the crew off on a ruled repository closes what it had | |
| 293 | parked. The sweep spends at most five model calls a tick and never re-asks a | |
| 294 | card already answered or undone. Only a repository admin who is a person sets | |
| 295 | it, never an agent. Set it in the repository's settings (Autonomy → Decisions | |
| 296 | here); over the API with `PATCH /api/v1/repos/<owner>/<name>` | |
| 297 | `{"decisionsBy": "me" | "decider" | "decider_tell"}` (admin token; it is in the | |
| 298 | repository JSON too); over MCP with `decisions_by` (`repo`; without `by` it | |
| 299 | says how the rule is set, with `by` it plans unless `dryRun: false`); in the | |
| 300 | terminal with `R` in the repository's workspace, which cycles me → decider → | |
| 301 | decider_tell. | |
| 302 | ||
| 285 | 303 | ## On your machine |
| 286 | 304 | |
| 287 | 305 | The forge holds the truth; `forge` keeps a copy of the parts you work on, the |
docs/MCP.md +1
| @@ -38,6 +38,7 @@ for a tunnel and a forge that sleeps. | ||
| 38 | 38 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 39 | 39 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
| 40 | 40 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 41 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | | |
| 41 | 42 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 42 | 43 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 43 | 44 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
scripts/gate.ts +2
| @@ -167,6 +167,8 @@ export const GATE: readonly string[] = [ | ||
| 167 | 167 | "test/review-wait.test.ts", |
| 168 | 168 | "test/asks-bulk.test.ts", |
| 169 | 169 | "test/hand-decider.test.ts", |
| 170 | "test/decisions-by-doors.test.ts", | |
| 171 | "test/standing-rule.test.ts", | |
| 170 | 172 | "test/asks-expiry.test.ts", |
| 171 | 173 | "test/asks-rank.test.ts", |
| 172 | 174 | "test/plan-pillars.test.ts", |
src/actions/asks_decide.ts +7 −18
| @@ -11,13 +11,12 @@ import { getDecision, latestDecision } from "../core/decisions.ts"; | ||
| 11 | 11 | import { getIssueById } from "../core/issues.ts"; |
| 12 | 12 | import { getRepoById } from "../core/repos.ts"; |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | import { letDeciderHandle, undoHandled } from "../crew/handover.ts"; | |
| 14 | import { guardedHand, letDeciderHandle, undoHandled } from "../crew/handover.ts"; | |
| 15 | 15 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 16 | 16 | import { askTargetsFor } from "../web/studio/asks.ts"; |
| 17 | 17 | import type { FleetError } from "./fleet.ts"; |
| 18 | 18 | |
| 19 | /** Every hand-over is a paid model call: one card is handed over once in this long. */ | |
| 20 | export const HAND_COOLDOWN_MS = 10 * 60_000; | |
| 19 | export { HAND_COOLDOWN_MS } from "../crew/handover.ts"; | |
| 21 | 20 | |
| 22 | 21 | /** What the decider did with a card: plain data for any door. */ |
| 23 | 22 | export interface HandAnswer { |
| @@ -35,9 +34,6 @@ export interface HandAnswer { | ||
| 35 | 34 | const fail = (code: FleetError["code"], message: string) => |
| 36 | 35 | err<FleetError>({ code, message }); |
| 37 | 36 | |
| 38 | /** Cards whose model call is in flight: a second tap must not pay again. */ | |
| 39 | const handing = new Set<string>(); | |
| 40 | ||
| 41 | 37 | const ASK_ID = /^(parked|issue):[A-Za-z0-9_-]{1,64}$/; |
| 42 | 38 | |
| 43 | 39 | function describe(ask: string, d: NonNullable<ReturnType<typeof getDecision>>): HandAnswer { |
| @@ -73,22 +69,15 @@ export async function decideAsk( | ||
| 73 | 69 | const repo = issue === undefined ? undefined : getRepoById(app.db, issue.repoId); |
| 74 | 70 | if (issue === undefined || repo === undefined) |
| 75 | 71 | return fail("not_found", "it no longer asks you"); |
| 76 | const last = latestDecision(app.db, issue.id, "hand"); | |
| 77 | if ( | |
| 78 | handing.has(issue.id) || | |
| 79 | (last !== undefined && now - Date.parse(last.createdAt) < HAND_COOLDOWN_MS) | |
| 80 | ) | |
| 72 | const handed = await guardedHand(app, issue.id, now, () => | |
| 73 | letDeciderHandle(app, actor, repo, issue, repo.decisionsBy), | |
| 74 | ); | |
| 75 | if (handed === "busy") | |
| 81 | 76 | return fail( |
| 82 | 77 | "too_many", |
| 83 | 78 | "the decider answered this a moment ago; try again in a few minutes", |
| 84 | 79 | ); |
| 85 | handing.add(issue.id); | |
| 86 | try { | |
| 87 | const { decision } = await letDeciderHandle(app, actor, repo, issue); | |
| 88 | return ok(describe(ask, decision)); | |
| 89 | } finally { | |
| 90 | handing.delete(issue.id); | |
| 91 | } | |
| 80 | return ok(describe(ask, handed.decision)); | |
| 92 | 81 | } |
| 93 | 82 | |
| 94 | 83 | /** Put a set-aside card back on the board, within a day. */ |
src/actions/repos.ts +38 −1
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | getSealedFork, |
| 24 | 24 | updateRepoSettings as writeSettings, |
| 25 | 25 | type CreateRepoError, |
| 26 | type DecisionsBy, | |
| 26 | 27 | type ForkRepoError, |
| 27 | 28 | type Repo, |
| 28 | 29 | type RepoSettings, |
| @@ -246,7 +247,11 @@ export async function updateRepoSettings( | ||
| 246 | 247 | if (!admin.ok) return admin; |
| 247 | 248 | // Who the crew obeys is a person's call: an agent never widens it, even |
| 248 | 249 | // one on a team that administers the repository (`crew/direct.ts`). |
| 249 | if (settings.crewOutsiders !== undefined && admin.value.kind === "agent") | |
| 250 | if ( | |
| 251 | (settings.crewOutsiders !== undefined || | |
| 252 | settings.decisionsBy !== undefined) && | |
| 253 | admin.value.kind === "agent" | |
| 254 | ) | |
| 250 | 255 | return err("not_found"); |
| 251 | 256 | // The work on a sealed report stays private until it merges into its |
| 252 | 257 | // base, whoever owns it (`core/sealed.ts`). |
| @@ -270,6 +275,7 @@ export async function updateRepoSettings( | ||
| 270 | 275 | "requireReview", |
| 271 | 276 | "requireChecks", |
| 272 | 277 | "crewOutsiders", |
| 278 | "decisionsBy", | |
| 273 | 279 | ] as const |
| 274 | 280 | ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]); |
| 275 | 281 | app.bus.append({ |
| @@ -321,6 +327,37 @@ export async function setCrewOutsiders( | ||
| 321 | 327 | return ok({ on: updated.value.crewOutsiders, was: repo.crewOutsiders, dryRun }); |
| 322 | 328 | } |
| 323 | 329 | |
| 330 | export interface DecisionsAnswer { | |
| 331 | /** Who answers the repository's decisions, after this call. */ | |
| 332 | readonly by: DecisionsBy; | |
| 333 | /** Who did before it. */ | |
| 334 | readonly was: DecisionsBy; | |
| 335 | /** Nothing changed: this was the plan. */ | |
| 336 | readonly dryRun: boolean; | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * The standing rule for the decisions a repository asks of its owner, for | |
| 341 | * the MCP and the terminal — the web form's choice. A repository admin's, | |
| 342 | * never an agent's; a dry run answers what would change and changes nothing. | |
| 343 | */ | |
| 344 | export async function setDecisionsBy( | |
| 345 | app: App, | |
| 346 | actor: User | undefined, | |
| 347 | repo: Repo, | |
| 348 | by: DecisionsBy, | |
| 349 | dryRun: boolean, | |
| 350 | ): Promise<Result<DecisionsAnswer, "not_found">> { | |
| 351 | const admin = requireRepoAdmin(app, actor, repo); | |
| 352 | if (!admin.ok || admin.value.kind === "agent") return err("not_found"); | |
| 353 | if (dryRun) return ok({ by, was: repo.decisionsBy, dryRun }); | |
| 354 | const updated = await updateRepoSettings(app, actor, repo, { | |
| 355 | decisionsBy: by, | |
| 356 | }); | |
| 357 | if (!updated.ok) return err("not_found"); | |
| 358 | return ok({ by: updated.value.decisionsBy, was: repo.decisionsBy, dryRun }); | |
| 359 | } | |
| 360 | ||
| 324 | 361 | export async function deleteRepo( |
| 325 | 362 | app: App, |
| 326 | 363 | actor: User | undefined, |
src/core/repos.ts +13 −1
| @@ -116,6 +116,8 @@ export interface Repo { | ||
| 116 | 116 | * default; on a public repository it hands strangers the owner's budget. |
| 117 | 117 | */ |
| 118 | 118 | readonly crewOutsiders: boolean; |
| 119 | /** The standing rule for decisions here: `me`, `decider`, or `decider_tell`. */ | |
| 120 | readonly decisionsBy: DecisionsBy; | |
| 119 | 121 | /** The issue the planner is given, when the map has run dry. */ |
| 120 | 122 | readonly directionIssueId: string | null; |
| 121 | 123 | /** The repo this was forked from, or null. */ |
| @@ -160,6 +162,7 @@ interface RepoRow { | ||
| 160 | 162 | autonomy_slots: number; |
| 161 | 163 | autonomy_by: string | null; |
| 162 | 164 | crew_outsiders: number; |
| 165 | decisions_by: string; | |
| 163 | 166 | direction_issue_id: string | null; |
| 164 | 167 | fork_parent_id: string | null; |
| 165 | 168 | sealed_for: string | null; |
| @@ -197,6 +200,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 197 | 200 | autonomySlots: row.autonomy_slots, |
| 198 | 201 | autonomyBy: row.autonomy_by, |
| 199 | 202 | crewOutsiders: row.crew_outsiders === 1, |
| 203 | decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me", | |
| 200 | 204 | directionIssueId: row.direction_issue_id, |
| 201 | 205 | forkParentId: row.fork_parent_id, |
| 202 | 206 | sealedFor: row.sealed_for, |
| @@ -1007,6 +1011,12 @@ export function canAdmin( | ||
| 1007 | 1011 | return accessAllows(orgAccessFor(db, repo, user), "admin"); |
| 1008 | 1012 | } |
| 1009 | 1013 | |
| 1014 | /** Who answers the decisions a repository asks of its owner. */ | |
| 1015 | export const DECISIONS_BY = ["me", "decider", "decider_tell"] as const; | |
| 1016 | export type DecisionsBy = (typeof DECISIONS_BY)[number]; | |
| 1017 | export const isDecisionsBy = (v: unknown): v is DecisionsBy => | |
| 1018 | typeof v === "string" && (DECISIONS_BY as readonly string[]).includes(v); | |
| 1019 | ||
| 1010 | 1020 | export interface RepoSettings { |
| 1011 | 1021 | visibility?: "public" | "private"; |
| 1012 | 1022 | description?: string; |
| @@ -1025,6 +1035,7 @@ export interface RepoSettings { | ||
| 1025 | 1035 | autonomyBy?: string | null; |
| 1026 | 1036 | /** Whether someone who cannot write here may start or steer a run. */ |
| 1027 | 1037 | crewOutsiders?: boolean; |
| 1038 | decisionsBy?: DecisionsBy; | |
| 1028 | 1039 | } |
| 1029 | 1040 | |
| 1030 | 1041 | /** Update the mutable repo fields, leaving unspecified ones untouched. */ |
| @@ -1037,7 +1048,7 @@ export function updateRepoSettings( | ||
| 1037 | 1048 | `UPDATE repo SET visibility = ?1, description = ?2, protect_default = ?3, |
| 1038 | 1049 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1039 | 1050 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1040 | autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13 | |
| 1051 | autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14 | |
| 1041 | 1052 | WHERE id = ?6`, |
| 1042 | 1053 | ).run( |
| 1043 | 1054 | settings.visibility ?? repo.visibility, |
| @@ -1053,6 +1064,7 @@ export function updateRepoSettings( | ||
| 1053 | 1064 | settings.autonomySlots ?? repo.autonomySlots, |
| 1054 | 1065 | settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy, |
| 1055 | 1066 | (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0, |
| 1067 | settings.decisionsBy ?? repo.decisionsBy, | |
| 1056 | 1068 | ); |
| 1057 | 1069 | return getRepoById(db, repo.id)!; |
| 1058 | 1070 | } |
src/core/schema.ts +6
| @@ -1735,6 +1735,12 @@ export const migrations: readonly string[] = [ | ||
| 1735 | 1735 | placed_at TEXT NOT NULL |
| 1736 | 1736 | ); |
| 1737 | 1737 | `, |
| 1738 | // 85 — a standing rule per repository (#1605): who answers the decisions | |
| 1739 | // it asks of its owner. \`me\` is what every repository did before. | |
| 1740 | ` | |
| 1741 | ALTER TABLE repo ADD COLUMN decisions_by TEXT NOT NULL DEFAULT 'me' | |
| 1742 | CHECK (decisions_by IN ('me', 'decider', 'decider_tell')); | |
| 1743 | `, | |
| 1738 | 1744 | ]; |
| 1739 | 1745 | |
| 1740 | 1746 | /** |
src/crew/handover.ts +99 −14
| @@ -16,6 +16,7 @@ import type { App } from "../app.ts"; | ||
| 16 | 16 | import { recentEnds } from "../core/ask_ledger.ts"; |
| 17 | 17 | import { |
| 18 | 18 | getDecision, |
| 19 | latestDecision, | |
| 19 | 20 | recordDecision, |
| 20 | 21 | setOutcome, |
| 21 | 22 | type Decision, |
| @@ -28,7 +29,7 @@ import { | ||
| 28 | 29 | } from "../core/interactions.ts"; |
| 29 | 30 | import type { Issue } from "../core/issues.ts"; |
| 30 | 31 | import { isPinned } from "../core/pins.ts"; |
| 31 | import type { Repo } from "../core/repos.ts"; | |
| 32 | import { canAdmin, getRepoById, type DecisionsBy, type Repo } from "../core/repos.ts"; | |
| 32 | 33 | import type { User } from "../core/users.ts"; |
| 33 | 34 | import { spentTodayForRepos } from "./autonomy.ts"; |
| 34 | 35 | import { certain, decideOr, type Answers, type Questions } from "./decide.ts"; |
| @@ -40,6 +41,36 @@ export const HAND_UNDO_MS = 86_400_000; | ||
| 40 | 41 | /** The least confidence at which an answer takes a card off the board. */ |
| 41 | 42 | export const SURE = 0.7; |
| 42 | 43 | |
| 44 | /** A card is handed to the decider at most once in this long. */ | |
| 45 | export const HAND_COOLDOWN_MS = 10 * 60_000; | |
| 46 | ||
| 47 | /** Cards whose model call is in flight: a tap and a tick must not both pay. */ | |
| 48 | const handing = new Set<string>(); | |
| 49 | ||
| 50 | /** | |
| 51 | * Run `go` for one card unless it was answered in the last ten minutes or a | |
| 52 | * call for it is in flight; then nothing runs and this says `"busy"`. | |
| 53 | */ | |
| 54 | export async function guardedHand<T>( | |
| 55 | app: App, | |
| 56 | issueId: string, | |
| 57 | now: number, | |
| 58 | go: () => Promise<T>, | |
| 59 | ): Promise<T | "busy"> { | |
| 60 | const last = latestDecision(app.db, issueId, "hand"); | |
| 61 | if ( | |
| 62 | handing.has(issueId) || | |
| 63 | (last !== undefined && now - Date.parse(last.createdAt) < HAND_COOLDOWN_MS) | |
| 64 | ) | |
| 65 | return "busy"; | |
| 66 | handing.add(issueId); | |
| 67 | try { | |
| 68 | return await go(); | |
| 69 | } finally { | |
| 70 | handing.delete(issueId); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 43 | 74 | const SET_ASIDE = "set_aside"; |
| 44 | 75 | const ASK_OWNER = "ask_owner"; |
| 45 | 76 | |
| @@ -102,6 +133,23 @@ export function handContext( | ||
| 102 | 133 | }; |
| 103 | 134 | } |
| 104 | 135 | |
| 136 | /** | |
| 137 | * The actor on a decision the standing rule took: no person's, so no one | |
| 138 | * viewer's dismissal. The ledger's `set_aside` outcome closes the card for | |
| 139 | * everyone it asks (`notDismissed` in `web/studio/asks.ts`). | |
| 140 | */ | |
| 141 | export const STANDING = "standing"; | |
| 142 | ||
| 143 | function dismissFor(app: App, owner: User, repo: Repo, issue: Issue): void { | |
| 144 | recordInteraction(app.db, { | |
| 145 | viewerId: owner.id, | |
| 146 | itemId: itemOf(issue), | |
| 147 | kind: "dismissed", | |
| 148 | repoId: repo.id, | |
| 149 | actorId: owner.id, | |
| 150 | }); | |
| 151 | } | |
| 152 | ||
| 105 | 153 | /** The dismissal item a parked card is set aside under (`askItemsOf`). */ |
| 106 | 154 | const itemOf = (issue: Issue): string => `ask:parked:${issue.id}`; |
| 107 | 155 | |
| @@ -120,6 +168,8 @@ export async function letDeciderHandle( | ||
| 120 | 168 | owner: User, |
| 121 | 169 | repo: Repo, |
| 122 | 170 | issue: Issue, |
| 171 | mode: DecisionsBy, | |
| 172 | standing = false, | |
| 123 | 173 | ): Promise<Handled> { |
| 124 | 174 | const context = handContext(app, owner, repo, issue); |
| 125 | 175 | const rule = (): Answers<typeof QUESTIONS> => ({ |
| @@ -132,10 +182,14 @@ export async function letDeciderHandle( | ||
| 132 | 182 | rule, |
| 133 | 183 | ); |
| 134 | 184 | const answer = decided.answers.hand; |
| 135 | const sure = answer.choice === SET_ASIDE && answer.p >= SURE; | |
| 185 | // Ruled "decider", its word stands; otherwise only a sure one does. | |
| 186 | const sure = | |
| 187 | answer.choice === SET_ASIDE && (mode === "decider" || answer.p >= SURE); | |
| 136 | 188 | const percent = Math.round(answer.p * 100); |
| 137 | 189 | const reason = sure |
| 138 | ? `Set aside, ${percent}% sure: nothing in the direction, the budget or your past decisions here asks for it now.` | |
| 190 | ? mode === "decider" && answer.p < SURE | |
| 191 | ? `Set aside, ${percent}% sure: this repository is ruled "decider", so its word stands.` | |
| 192 | : `Set aside, ${percent}% sure: nothing in the direction, the budget or your past decisions here asks for it now.` | |
| 139 | 193 | : decided.by === "rule" |
| 140 | 194 | ? "No decider answered, so it stays with you." |
| 141 | 195 | : `Unsure (${percent}% for ${answer.choice === SET_ASIDE ? "setting it aside" : "asking you"}), so it stays with you.`; |
| @@ -143,20 +197,14 @@ export async function letDeciderHandle( | ||
| 143 | 197 | kind: "hand", |
| 144 | 198 | repoId: repo.id, |
| 145 | 199 | issueId: issue.id, |
| 146 | state: { ...context, reason, actor: owner.id }, | |
| 200 | state: { ...context, reason, actor: standing ? STANDING : owner.id }, | |
| 147 | 201 | answer: sure ? SET_ASIDE : ASK_OWNER, |
| 148 | 202 | p: answer.p, |
| 149 | 203 | by: decided.by, |
| 150 | 204 | ms: decided.ms, |
| 151 | 205 | }); |
| 152 | 206 | if (sure) { |
| 153 | recordInteraction(app.db, { | |
| 154 | viewerId: owner.id, | |
| 155 | itemId: itemOf(issue), | |
| 156 | kind: "dismissed", | |
| 157 | repoId: repo.id, | |
| 158 | actorId: owner.id, | |
| 159 | }); | |
| 207 | if (!standing) dismissFor(app, owner, repo, issue); | |
| 160 | 208 | setOutcome(app.db, decision.id, "set_aside"); |
| 161 | 209 | } else setOutcome(app.db, decision.id, "asked"); |
| 162 | 210 | return { decision: getDecision(app.db, decision.id)!, setAside: sure }; |
| @@ -183,10 +231,15 @@ export function undoHandled( | ||
| 183 | 231 | // Someone else's set-aside is not found: only the dismissal's owner undoes it. |
| 184 | 232 | const item = `ask:parked:${decision.issueId}`; |
| 185 | 233 | const said = decision.state as { actor?: unknown } | null; |
| 186 | if (said?.actor !== owner.id) return "not_found"; | |
| 187 | if (!askDismissals(app.db, owner.id).has(item)) return "not_found"; | |
| 234 | const byRule = said?.actor === STANDING; | |
| 235 | if (!byRule && said?.actor !== owner.id) return "not_found"; | |
Low, not blocking. A person's own tap that ends |
||
| 236 | if (byRule) { | |
| 237 | // The rule's set-aside is the repository's: any admin of it may take it back. | |
| 238 | const repo = getRepoById(app.db, decision.repoId); | |
| 239 | if (repo === undefined || !canAdmin(app.db, repo, owner)) return "not_found"; | |
| 240 | } else if (!askDismissals(app.db, owner.id).has(item)) return "not_found"; | |
| 188 | 241 | if (now - Date.parse(decision.createdAt) > HAND_UNDO_MS) return "too_late"; |
| 189 | removeDismissals(app.db, owner.id, [item]); | |
| 242 | if (!byRule) removeDismissals(app.db, owner.id, [item]); | |
| 190 | 243 | setOutcome(app.db, decision.id, "undone"); |
| 191 | 244 | return getDecision(app.db, decision.id)!; |
| 192 | 245 | } |
| @@ -206,3 +259,35 @@ export function forReader(d: Decision, reader: User | undefined): Decision { | ||
| 206 | 259 | state: { ...rest, repository: { name: repository?.name } }, |
| 207 | 260 | }; |
| 208 | 261 | } |
| 262 | ||
| 263 | /** | |
| 264 | * A hand-over on a shelved repository — its crew is off, so nothing will | |
| 265 | * pick the issue up again — closes without a model call: the rule answers, | |
| 266 | * in the ledger, with the same context and the same undo. Only the standing | |
| 267 | * sweep calls it, so it is recorded as the standing rule's and dismisses for | |
| 268 | * no one. | |
| 269 | */ | |
| 270 | export function setAsideShelved( | |
| 271 | app: App, | |
| 272 | owner: User, | |
| 273 | repo: Repo, | |
outdated
Dead parameter: the only caller ( |
||
| 274 | issue: Issue, | |
| 275 | ): Handled { | |
| 276 | const context = handContext(app, owner, repo, issue); | |
| 277 | const decision = recordDecision(app.db, { | |
| 278 | kind: "hand", | |
| 279 | repoId: repo.id, | |
| 280 | issueId: issue.id, | |
| 281 | state: { | |
| 282 | ...context, | |
| 283 | reason: "Set aside: the crew is off here, so nothing will pick it up.", | |
| 284 | actor: STANDING, | |
| 285 | }, | |
| 286 | answer: SET_ASIDE, | |
| 287 | p: 1, | |
| 288 | by: "rule", | |
| 289 | ms: 0, | |
| 290 | }); | |
outdated
This branch never runs, because |
||
| 291 | setOutcome(app.db, decision.id, "set_aside"); | |
| 292 | return { decision: getDecision(app.db, decision.id)!, setAside: true }; | |
| 293 | } |
src/crew/standing.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * The standing rule: a repository ruled `decider` or `decider_tell` has its | |
| 3 | * hand-overs answered without a person tapping. A sweep on the asks tick | |
| 4 | * looks at each parked card of such a repository once per park: a shelved | |
| 5 | * repository's closes by rule with no model call, any other goes to the | |
| 6 | * decider (one small-model call), and a card already answered since it was | |
| 7 | * parked is left alone — an unsure one waits for its owner, an undone one | |
| 8 | * stays undone. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { latestDecision } from "../core/decisions.ts"; | |
| 13 | import { askDismissals } from "../core/interactions.ts"; | |
| 14 | import { getIssueById } from "../core/issues.ts"; | |
| 15 | import { listEveryRepo } from "../core/repos.ts"; | |
| 16 | import { getUserById } from "../core/users.ts"; | |
| 17 | import { breakerParked } from "./breaker.ts"; | |
| 18 | import { guardedHand, letDeciderHandle, setAsideShelved } from "./handover.ts"; | |
| 19 | ||
| 20 | /** Model calls one sweep may make: a flood of parks is not a flood of spend. */ | |
| 21 | export const SWEEP_CALLS = 5; | |
| 22 | ||
| 23 | export interface Swept { | |
| 24 | readonly byRule: number; | |
| 25 | readonly byDecider: number; | |
| 26 | } | |
| 27 | ||
| 28 | export async function sweepStanding(app: App): Promise<Swept> { | |
| 29 | let byRule = 0; | |
| 30 | let byDecider = 0; | |
| 31 | const ruled = listEveryRepo(app.db).filter((r) => r.decisionsBy !== "me"); | |
| 32 | if (ruled.length === 0) return { byRule, byDecider }; | |
| 33 | const byId = new Map(ruled.map((r) => [r.id, r])); | |
| 34 | for (const p of breakerParked(app.db, [...byId.keys()])) { | |
| 35 | const repo = byId.get(p.repoId)!; | |
| 36 | const issue = getIssueById(app.db, p.issueId); | |
| 37 | const owner = getUserById(app.db, repo.ownerId); | |
High: on an org repository |
||
| 38 | if (issue === undefined || owner === undefined) continue; | |
| 39 | const last = latestDecision(app.db, issue.id, "hand"); | |
| 40 | if (last !== undefined && last.createdAt >= p.at) continue; | |
| 41 | // The owner's own dismissal holds until the park is newer than it. | |
outdated
|
||
| 42 | if ((askDismissals(app.db, owner.id).get(`ask:parked:${issue.id}`) ?? "") >= p.at) | |
| 43 | continue; | |
| 44 | // The rule is nobody's tap: it closes the card in the ledger for every | |
| 45 | // admin it asks, and its decision names no person as the actor. | |
| 46 | if (repo.autonomy === "off") { | |
outdated
This skips the |
||
| 47 | setAsideShelved(app, owner, repo, issue); | |
| 48 | byRule++; | |
| 49 | } else if (app.config.decider !== undefined && byDecider < SWEEP_CALLS) { | |
| 50 | const handed = await guardedHand(app, issue.id, Date.now(), () => | |
| 51 | letDeciderHandle(app, owner, repo, issue, repo.decisionsBy, true), | |
| 52 | ); | |
| 53 | if (handed !== "busy") byDecider++; | |
| 54 | } | |
| 55 | } | |
| 56 | return { byRule, byDecider }; | |
| 57 | } |
src/http/api.ts +11
| @@ -143,6 +143,7 @@ import { audienceFilter } from "../core/event_audience.ts"; | ||
| 143 | 143 | import { |
| 144 | 144 | forkParentFor, |
| 145 | 145 | getReposByIds, |
| 146 | isDecisionsBy, | |
| 146 | 147 | } from "../core/repos.ts"; |
| 147 | 148 | import { |
| 148 | 149 | commentCounts, |
| @@ -1732,6 +1733,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1732 | 1733 | const repo = loadRepo(ctx); |
| 1733 | 1734 | if (!repo) return notFound(); |
| 1734 | 1735 | const input = await body(ctx.request); |
| 1736 | if (input.decisionsBy !== undefined && !isDecisionsBy(input.decisionsBy)) | |
| 1737 | return apiError( | |
| 1738 | "bad_decisions_by", | |
| 1739 | 422, | |
| 1740 | "decisionsBy is me, decider or decider_tell", | |
| 1741 | ); | |
| 1735 | 1742 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { |
| 1736 | 1743 | ...(input.visibility === "public" || input.visibility === "private" |
| 1737 | 1744 | ? { visibility: input.visibility } |
| @@ -1755,6 +1762,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1755 | 1762 | ...(typeof input.crewOutsiders === "boolean" |
| 1756 | 1763 | ? { crewOutsiders: input.crewOutsiders } |
| 1757 | 1764 | : {}), |
| 1765 | // The standing rule for its decisions: also an admin's, never an agent's. | |
| 1766 | ...(isDecisionsBy(input.decisionsBy) | |
| 1767 | ? { decisionsBy: input.decisionsBy } | |
| 1768 | : {}), | |
| 1758 | 1769 | }); |
| 1759 | 1770 | if (!updated.ok) return respondAccess(updated.error, 422); |
| 1760 | 1771 | return json(repoJson(ctx, updated.value)); |
src/http/mcp.ts +41 −1
| @@ -90,6 +90,7 @@ import { | ||
| 90 | 90 | canWrite, |
| 91 | 91 | forkParentFor, |
| 92 | 92 | getRepo, |
| 93 | isDecisionsBy, | |
| 93 | 94 | ownerFactRepoIds, |
| 94 | 95 | type Repo, |
| 95 | 96 | } from "../core/repos.ts"; |
| @@ -106,7 +107,7 @@ import { | ||
| 106 | 107 | directionNote, |
| 107 | 108 | OUTSIDERS_NOTE, |
| 108 | 109 | } from "../crew/direct.ts"; |
| 109 | import { setCrewOutsiders } from "../actions/repos.ts"; | |
| 110 | import { setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts"; | |
| 110 | 111 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 111 | 112 | import { undoFleetOp } from "../actions/fleet.ts"; |
| 112 | 113 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| @@ -873,6 +874,45 @@ const TOOLS: readonly Tool[] = [ | ||
| 873 | 874 | }); |
| 874 | 875 | }, |
| 875 | 876 | }, |
| 877 | { | |
| 878 | name: "decisions_by", | |
| 879 | description: | |
| 880 | "The standing rule for the decisions a repository asks of its owner (parked hand-over cards): `me` (they wait for the owner), `decider` (the decider answers each, and a repository whose crew is off closes its parked cards by rule, with no model call), or `decider_tell` (the decider answers, and the owner is told about the ones it is unsure of). Without `by`, says how it is set. With `by`, it plans — `dryRun` is true unless you say false — and only an admin of the app, with an admin token and never as an agent, sets it. Each change is recorded in the app's activity.", | |
| 881 | inputSchema: { | |
| 882 | type: "object", | |
| 883 | properties: { | |
| 884 | repo: REPO_ARG, | |
| 885 | by: { type: "string", enum: ["me", "decider", "decider_tell"] }, | |
| 886 | dryRun: { | |
| 887 | type: "boolean", | |
| 888 | description: "Plan only (the default). Send false to set it.", | |
| 889 | }, | |
| 890 | }, | |
| 891 | required: ["repo"], | |
| 892 | }, | |
| 893 | writes: true, | |
| 894 | dryByDefault: true, | |
| 895 | async run(ctx, user, args) { | |
| 896 | const repo = repoArg(ctx, user, args); | |
| 897 | if (isAnswer(repo)) return repo; | |
| 898 | const by = args["by"]; | |
| 899 | if (by === undefined) return good({ by: repo.decisionsBy }); | |
| 900 | if (!isDecisionsBy(by)) | |
| 901 | return bad("bad_action", "`by` is me, decider or decider_tell"); | |
| 902 | const dryRun = args["dryRun"] !== false; | |
| 903 | if (!dryRun && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 904 | return bad("forbidden", "setting the standing rule needs an admin token"); | |
| 905 | const set = await setDecisionsBy(ctx.app, user, repo, by, dryRun); | |
| 906 | if (!set.ok) | |
| 907 | return bad("forbidden", "only an admin of this app sets its standing rule"); | |
| 908 | return good({ | |
| 909 | ...set.value, | |
| 910 | says: dryRun | |
| 911 | ? `This is the plan; nothing changed. Decisions go by "${set.value.was}" now; send dryRun: false to make it "${set.value.by}".` | |
| 912 | : `Decisions here now go by "${set.value.by}".`, | |
| 913 | }); | |
| 914 | }, | |
| 915 | }, | |
| 876 | 916 | { |
| 877 | 917 | name: "probe", |
| 878 | 918 | description: |
src/main.ts +7
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { sweepStanding } from "./crew/standing.ts"; | |
| 7 | 8 | import { handover } from "./release/swap.ts"; |
| 8 | 9 | import { parentUrlProblem } from "./core/parent_releases.ts"; |
| 9 | 10 | import { releaseEnv } from "./release/env.ts"; |
| @@ -364,6 +365,12 @@ runSync(app); // one pass on boot: a forge that was down has fetching to do | ||
| 364 | 365 | // expired: what asked each person, and how it ended. |
| 365 | 366 | const tickAsks = async (): Promise<void> => { |
| 366 | 367 | await sweepStaleAsks(app); |
| 368 | // Repositories ruled "decider" have their hand-overs answered here. | |
| 369 | try { | |
| 370 | await sweepStanding(app); | |
| 371 | } catch (error) { | |
| 372 | app.log.error("standing rule sweep failed", { error: String(error) }); | |
| 373 | } | |
| 367 | 374 | try { |
| 368 | 375 | recordAskLedger(app.db); |
| 369 | 376 | } catch (error) { |
src/tui/screens/workspace.ts +21 −1
| @@ -39,7 +39,8 @@ import { err, ok } from "../../kernel/result.ts"; | ||
| 39 | 39 | import { createBranch } from "../../actions/branches.ts"; |
| 40 | 40 | import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts"; |
| 41 | 41 | import { isPinned } from "../../core/pins.ts"; |
| 42 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 42 | import { setDecisionsBy } from "../../actions/repos.ts"; | |
| 43 | import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 43 | 44 | import type { TreeEntry } from "../../git/read.ts"; |
| 44 | 45 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 45 | 46 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| @@ -1283,6 +1284,9 @@ export function workspaceScreen( | ||
| 1283 | 1284 | binding(["y"], "copy path"), |
| 1284 | 1285 | binding(["i", "p", "C"], "issues / pulls / commits"), |
| 1285 | 1286 | binding(["I"], "about this repository"), |
| 1287 | ...(repo !== undefined && admin() | |
| 1288 | ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)] | |
| 1289 | : []), | |
| 1286 | 1290 | ...liveBindings(src), |
| 1287 | 1291 | ...(liveSide?.mirroredAs === undefined |
| 1288 | 1292 | ? [] |
| @@ -1670,6 +1674,22 @@ export function workspaceScreen( | ||
| 1670 | 1674 | ); |
| 1671 | 1675 | return true; |
| 1672 | 1676 | } |
| 1677 | if (plain && e.key === "R" && repo !== undefined && admin()) { | |
| 1678 | // `repo` was read when the screen opened; the rule is read afresh. | |
| 1679 | const target = getRepoById(f.db, repo.id) ?? repo; | |
| 1680 | const next = | |
| 1681 | DECISIONS_BY[(DECISIONS_BY.indexOf(target.decisionsBy) + 1) % DECISIONS_BY.length]!; | |
| 1682 | runAsync(ctx, async () => { | |
| 1683 | const done = await setDecisionsBy(f.app, f.user, target, next, false); | |
| 1684 | if (!done.ok) { | |
| 1685 | ctx.toast("only a person who administers this sets its standing rule", "danger"); | |
| 1686 | return; | |
| 1687 | } | |
| 1688 | reload(); | |
| 1689 | ctx.toast(`decisions here go by ${next}`, "success"); | |
| 1690 | }); | |
| 1691 | return true; | |
| 1692 | } | |
| 1673 | 1693 | if (plain && e.key === "r") { |
| 1674 | 1694 | cache.clear(); |
| 1675 | 1695 | runAsync(ctx, reload); |
src/web/pages/settings.ts +10 −1
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | } from "../../core/app_env.ts"; |
| 40 | 40 | import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; |
| 41 | 41 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 42 | import { isAutonomy } from "../../core/repos.ts"; | |
| 42 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; | |
| 43 | 43 | import { |
| 44 | 44 | createLabel, |
| 45 | 45 | createWebhook, |
| @@ -1759,12 +1759,21 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 1759 | 1759 | message: |
| 1760 | 1760 | "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 1 to 8.", |
| 1761 | 1761 | }); |
| 1762 | const rule = field(form, "decisions"); | |
| 1763 | const decisionsBy = rule === "" ? repo.decisionsBy : rule; | |
This always sends |
||
| 1764 | if (!isDecisionsBy(decisionsBy)) | |
| 1765 | return flashTo(ctx, `${base}/settings#autonomy`, { | |
| 1766 | tone: "danger", | |
| 1767 | message: "Decisions here go to you, the decider, or the decider telling you.", | |
| 1768 | }); | |
| 1762 | 1769 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { |
| 1763 | 1770 | autonomy: mode, |
| 1764 | 1771 | autonomyDirection: field(form, "direction").trim().slice(0, 2000), |
| 1765 | 1772 | autonomyCeilingUsd: ceiling, |
| 1766 | 1773 | autonomySlots: slots, |
| 1767 | 1774 | autonomyBy: mode === "off" ? null : (ctx.auth.user?.id ?? null), |
| 1775 | // Only a change is sent: an agent admin may save the rest of the form. | |
| 1776 | ...(decisionsBy === repo.decisionsBy ? {} : { decisionsBy }), | |
| 1768 | 1777 | }); |
| 1769 | 1778 | return flashTo(ctx, `${base}/settings#autonomy`, { |
| 1770 | 1779 | ...(updated.ok |
src/web/studio/asks.ts +11 −2
| @@ -490,12 +490,21 @@ function notDismissed<T extends AskCandidate>( | ||
| 490 | 490 | ): T[] { |
| 491 | 491 | const muted = mutedRepos(db, user.id); |
| 492 | 492 | const dismissed = askDismissals(db, user.id); |
| 493 | if (dismissed.size === 0 && muted.size === 0) return [...asks]; | |
| 494 | 493 | const holds = (id: string, since: string): boolean => |
| 495 | 494 | (dismissed.get(`ask:${id}`) ?? "") > since; |
| 495 | // A hand-over the decider or the standing rule set aside is closed for | |
| 496 | // everyone it asks, not for one viewer: the ledger holds it. | |
| 497 | const setAside = (a: T): boolean => { | |
| 498 | if (a.kind !== "parked" || a.issue === undefined) return false; | |
| 499 | const last = latestDecision(db, a.issue.id, "hand"); | |
| 500 | return last?.outcome === "set_aside" && last.createdAt >= a.since; | |
| 501 | }; | |
| 496 | 502 | return asks.filter( |
| 497 | 503 | (a) => |
| 498 | !muted.has(a.repo.id) && !holds(a.id, a.since) && !holds(groupId(a), a.since), | |
| 504 | !muted.has(a.repo.id) && | |
| 505 | !holds(a.id, a.since) && | |
| 506 | !holds(groupId(a), a.since) && | |
| 507 | !setAside(a), | |
| 499 | 508 | ); |
| 500 | 509 | } |
| 501 | 510 |
src/web/studio/crew.ts +18
| @@ -326,6 +326,24 @@ ${repo.autonomyDirection}</textarea | ||
| 326 | 326 | map is only what people write.`} |
| 327 | 327 | </p> |
| 328 | 328 | </div> |
| 329 | ${selectField({ | |
| 330 | label: "Decisions here", | |
| 331 | name: "decisions", | |
| 332 | hint: "Who answers the hand-overs this asks of you. The decider answers in the ledger with its reason and context; ‘tell me’ sets a card aside only when it is sure and leaves the unsure ones on your board. A shelved repository (autonomy off) has its parked cards closed by rule, with no model call, when decisions go to the decider. Needs a decider set up on this forge.", | |
| 333 | options: [ | |
| 334 | { value: "me", label: "Me", selected: repo.decisionsBy === "me" }, | |
| 335 | { | |
| 336 | value: "decider", | |
| 337 | label: "The decider", | |
| 338 | selected: repo.decisionsBy === "decider", | |
| 339 | }, | |
| 340 | { | |
| 341 | value: "decider_tell", | |
| 342 | label: "The decider, and tell me the ones it was unsure of", | |
| 343 | selected: repo.decisionsBy === "decider_tell", | |
| 344 | }, | |
| 345 | ], | |
| 346 | })} | |
| 329 | 347 | ${field({ |
| 330 | 348 | label: "Ceiling per day", |
| 331 | 349 | name: "ceiling", |
test/decisions-by-doors.test.ts added +257
| @@ -0,0 +1,257 @@ | ||
| 1 | /** | |
| 2 | * The standing rule for a repository's decisions is reachable beyond the web | |
| 3 | * form (#1605): the JSON API's repository settings, an MCP tool that plans by | |
| 4 | * default, and a key in the terminal workspace. Only an admin who is not an | |
| 5 | * agent sets it. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import type { Agent } from "../src/core/agents.ts"; | |
| 14 | import { | |
outdated
|
||
| 15 | addTeamMember, | |
| 16 | createOrg, | |
| 17 | createTeam, | |
| 18 | listTeams, | |
| 19 | OWNERS_TEAM, | |
| 20 | } from "../src/core/orgs.ts"; | |
| 21 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 22 | import { createToken } from "../src/core/tokens.ts"; | |
| 23 | import { createUser, type User } from "../src/core/users.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 27 | import { quietLogs } from "./helpers/globals.ts"; | |
| 28 | import { settleApp } from "./helpers/tui.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | let dataDir: string; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | let founder: User; | |
| 36 | let dev: User; | |
| 37 | let mallory: User; | |
| 38 | let bot: Agent; | |
| 39 | let repo: Repo; | |
| 40 | const admin: Record<string, string> = {}; | |
| 41 | ||
| 42 | const ok = <T>( | |
| 43 | r: { ok: true; value: T } | { ok: false; error: unknown }, | |
| 44 | ): T => { | |
| 45 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 46 | return r.value; | |
| 47 | }; | |
| 48 | const rule = (): string => getRepoById(app.db, repo.id)!.decisionsBy; | |
| 49 | ||
| 50 | const patch = (token: string, body: unknown) => | |
| 51 | fetch(`${servers.url}/api/v1/repos/acme/svc`, { | |
| 52 | method: "PATCH", | |
| 53 | headers: { | |
| 54 | authorization: `Bearer ${token}`, | |
| 55 | "content-type": "application/json", | |
| 56 | }, | |
| 57 | body: JSON.stringify(body), | |
| 58 | }); | |
| 59 | ||
| 60 | async function mcp( | |
| 61 | token: string, | |
| 62 | name: string, | |
| 63 | args: unknown, | |
| 64 | ): Promise<{ error?: string; value?: any }> { | |
| 65 | const res = await fetch(`${servers.url}/mcp`, { | |
| 66 | method: "POST", | |
| 67 | headers: { | |
| 68 | "content-type": "application/json", | |
| 69 | accept: "application/json, text/event-stream", | |
| 70 | authorization: `Bearer ${token}`, | |
| 71 | }, | |
| 72 | body: JSON.stringify({ | |
| 73 | jsonrpc: "2.0", | |
| 74 | id: 1, | |
| 75 | method: "tools/call", | |
| 76 | params: { name, arguments: args }, | |
| 77 | }), | |
| 78 | }); | |
| 79 | const body = (await res.json()) as any; | |
| 80 | const text: string = body.result.content[0].text; | |
| 81 | return body.result.isError === true | |
| 82 | ? { error: text } | |
| 83 | : { value: JSON.parse(text) }; | |
| 84 | } | |
| 85 | ||
| 86 | beforeAll(async () => { | |
| 87 | dataDir = await mkdtemp(join(tmpdir(), "forge-decisions-by-")); | |
| 88 | app = createApp({ dataDir, port: 0 }); | |
| 89 | servers = await serve(app); | |
| 90 | const make = async (username: string) => | |
| 91 | ok( | |
| 92 | await createUser(app.db, { | |
| 93 | trusted: true, | |
| 94 | username, | |
| 95 | email: `${username}@forge.local`, | |
| 96 | password: "a-good-passphrase", | |
| 97 | adminIfFirst: true, | |
| 98 | }), | |
| 99 | ); | |
| 100 | founder = await make("founder"); | |
| 101 | dev = await make("dev"); | |
| 102 | mallory = await make("mallory"); | |
| 103 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 104 | const writers = ok(createTeam(app.db, org.id, "Writers", "write")); | |
| 105 | addTeamMember(app.db, writers, dev.id); | |
| 106 | repo = ok( | |
| 107 | await createRepo(app.db, app.reposRoot, { | |
| 108 | ownerId: org.id, | |
| 109 | name: "svc", | |
| 110 | visibility: "public", | |
| 111 | }), | |
| 112 | ); | |
| 113 | bot = ok(await createAgent(app, founder, { username: "bot" })); | |
| 114 | expect(addToCrew(app, founder, repo, "bot").ok).toBe(true); | |
| 115 | // The hardest agent to refuse: on the team that administers the repository. | |
| 116 | const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 117 | addTeamMember(app.db, owners, bot.user.id); | |
| 118 | for (const u of [founder, dev, mallory, bot.user]) | |
| 119 | admin[u.username] = createToken(app.db, u.id, "admin", "admin").token; | |
| 120 | }, 30_000); | |
| 121 | ||
| 122 | afterAll(async () => { | |
| 123 | servers?.stop(); | |
| 124 | app?.close(); | |
| 125 | await rm(dataDir, { recursive: true, force: true }); | |
| 126 | }); | |
| 127 | ||
| 128 | describe("the standing rule over the API", () => { | |
| 129 | test("anyone but an admin who is a person is refused", async () => { | |
| 130 | for (const who of ["dev", "mallory", "bot"]) { | |
| 131 | const res = await patch(admin[who]!, { decisionsBy: "decider" }); | |
| 132 | expect(res.status).toBe(404); | |
| 133 | expect(rule()).toBe("me"); | |
| 134 | } | |
| 135 | }); | |
| 136 | ||
| 137 | test("the admin sets it and reads it back; a stranger value is refused", async () => { | |
| 138 | const set = await patch(admin["founder"]!, { decisionsBy: "decider_tell" }); | |
| 139 | expect(set.status).toBe(200); | |
| 140 | expect(((await set.json()) as { decisionsBy: string }).decisionsBy).toBe("decider_tell"); | |
| 141 | expect(rule()).toBe("decider_tell"); | |
| 142 | expect((await patch(admin["founder"]!, { decisionsBy: "anyone" })).status).toBe(422); | |
| 143 | expect((await patch(admin["founder"]!, { description: "x" })).status).toBe(200); | |
| 144 | expect(rule()).toBe("decider_tell"); | |
| 145 | const read = await fetch(`${servers.url}/api/v1/repos/acme/svc`, { | |
| 146 | headers: { authorization: `Bearer ${admin["mallory"]}` }, | |
| 147 | }); | |
| 148 | expect(((await read.json()) as { decisionsBy: string }).decisionsBy).toBe("decider_tell"); | |
| 149 | await patch(admin["founder"]!, { decisionsBy: "me" }); | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 153 | describe("the standing rule over MCP", () => { | |
| 154 | test("without `by` it says how it is set; with `by` it plans unless told otherwise", async () => { | |
| 155 | const said = await mcp(admin["mallory"]!, "decisions_by", { repo: "acme/svc" }); | |
| 156 | expect(said.value.by).toBe("me"); | |
| 157 | const planned = await mcp(admin["founder"]!, "decisions_by", { | |
| 158 | repo: "acme/svc", | |
| 159 | by: "decider", | |
| 160 | }); | |
| 161 | expect(planned.value).toMatchObject({ dryRun: true, by: "decider", was: "me" }); | |
| 162 | expect(rule()).toBe("me"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a writer, a reader and an agent cannot set it, nor plan it; a bad value is refused", async () => { | |
| 166 | for (const who of ["dev", "mallory", "bot"]) | |
| 167 | for (const dryRun of [true, false]) { | |
| 168 | const tried = await mcp(admin[who]!, "decisions_by", { | |
| 169 | repo: "acme/svc", | |
| 170 | by: "decider", | |
| 171 | dryRun, | |
| 172 | }); | |
| 173 | expect(tried.error).toContain("forbidden"); | |
| 174 | expect(rule()).toBe("me"); | |
| 175 | } | |
| 176 | const odd = await mcp(admin["founder"]!, "decisions_by", { repo: "acme/svc", by: "x" }); | |
| 177 | expect(odd.error).toContain("me, decider or decider_tell"); | |
| 178 | }); | |
| 179 | ||
| 180 | test("the admin needs an admin token to set it", async () => { | |
| 181 | const rw = createToken(app.db, founder.id, "rw", "rw").token; | |
| 182 | const refused = await mcp(rw, "decisions_by", { repo: "acme/svc", by: "decider", dryRun: false }); | |
| 183 | expect(refused.error).toContain("forbidden"); | |
| 184 | expect(rule()).toBe("me"); | |
| 185 | const set = await mcp(admin["founder"]!, "decisions_by", { | |
| 186 | repo: "acme/svc", | |
| 187 | by: "decider", | |
| 188 | dryRun: false, | |
| 189 | }); | |
| 190 | expect(set.value).toMatchObject({ dryRun: false, by: "decider", was: "me" }); | |
| 191 | expect(rule()).toBe("decider"); | |
| 192 | }); | |
| 193 | ||
| 194 | test("asks_decide no longer takes a `by`", async () => { | |
| 195 | const tools = await fetch(`${servers.url}/mcp`, { | |
| 196 | method: "POST", | |
| 197 | headers: { | |
| 198 | "content-type": "application/json", | |
| 199 | accept: "application/json, text/event-stream", | |
| 200 | authorization: `Bearer ${admin["founder"]}`, | |
| 201 | }, | |
| 202 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | |
| 203 | }); | |
| 204 | const list = ((await tools.json()) as any).result.tools as any[]; | |
| 205 | expect(list.find((t) => t.name === "asks_decide").inputSchema.properties.by).toBeUndefined(); | |
| 206 | }); | |
| 207 | }); | |
| 208 | ||
| 209 | describe("the standing rule in the terminal", () => { | |
| 210 | test("R cycles it for an admin who is a person, and says so", async () => { | |
| 211 | app.db.run("UPDATE repo SET decisions_by = 'me' WHERE id = ?1", [repo.id]); | |
| 212 | const terminal = headlessTerminal(160, 40); | |
| 213 | const { app: tui } = createForgeApp( | |
| 214 | app, | |
| 215 | { sshUsername: "founder", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: founder }, | |
| 216 | { terminal, exitHooks: false }, | |
| 217 | ); | |
| 218 | tui.start(); | |
| 219 | const press = async (...keys: string[]) => { | |
| 220 | for (const key of keys) { | |
| 221 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 222 | await settleApp(tui); | |
| 223 | } | |
| 224 | }; | |
| 225 | await press("/"); | |
| 226 | for (const ch of "svc") await press(ch); | |
| 227 | await press("enter", "enter"); | |
| 228 | await press("R"); | |
| 229 | expect(rule()).toBe("decider"); | |
| 230 | expect(tui.screen.toText()).toContain("decisions here go by decider"); | |
| 231 | await press("R"); | |
| 232 | expect(rule()).toBe("decider_tell"); | |
| 233 | tui.stop(); | |
| 234 | }); | |
| 235 | ||
| 236 | test("a reader's R changes nothing", async () => { | |
| 237 | app.db.run("UPDATE repo SET decisions_by = 'me' WHERE id = ?1", [repo.id]); | |
| 238 | const terminal = headlessTerminal(160, 40); | |
| 239 | const { app: tui } = createForgeApp( | |
| 240 | app, | |
| 241 | { sshUsername: "mallory", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: mallory }, | |
| 242 | { terminal, exitHooks: false }, | |
| 243 | ); | |
| 244 | tui.start(); | |
| 245 | const press = async (...keys: string[]) => { | |
| 246 | for (const key of keys) { | |
| 247 | tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 248 | await settleApp(tui); | |
| 249 | } | |
| 250 | }; | |
| 251 | await press("/"); | |
| 252 | for (const ch of "svc") await press(ch); | |
| 253 | await press("enter", "enter", "R"); | |
| 254 | expect(rule()).toBe("me"); | |
| 255 | tui.stop(); | |
| 256 | }); | |
| 257 | }); |
test/mcp.test.ts +1
| @@ -175,6 +175,7 @@ describe("the forge over MCP", () => { | ||
| 175 | 175 | "health", |
| 176 | 176 | "audit", |
| 177 | 177 | "outsiders", |
| 178 | "decisions_by", | |
| 178 | 179 | "probe", |
| 179 | 180 | "following", |
| 180 | 181 | "spawn", |
test/standing-rule.test.ts added +236
| @@ -0,0 +1,236 @@ | ||
| 1 | /** | |
| 2 | * A standing rule per repository: hand-overs on a repository ruled | |
| 3 | * "decider" are answered by the sweep without a person; a shelved one | |
| 4 | * closes with no model call; "decider_tell" leaves the unsure ones; the | |
| 5 | * settings form saves the rule; a manual tap follows it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addComment, openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { decideAsk } from "../src/actions/asks_decide.ts"; | |
| 15 | import { createOrg } from "../src/core/orgs.ts"; | |
| 16 | import { recordInteraction } from "../src/core/interactions.ts"; | |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 18 | import { listDecisions } from "../src/core/decisions.ts"; | |
| 19 | import type { Issue } from "../src/core/issues.ts"; | |
| 20 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 21 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 22 | import { BREAKER_NOTE } from "../src/crew/breaker.ts"; | |
| 23 | import type { Decider } from "../src/crew/decide.ts"; | |
| 24 | import { undoHandled } from "../src/crew/handover.ts"; | |
| 25 | import { sweepStanding } from "../src/crew/standing.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { askLinesFor } from "../src/web/studio/asks.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | describe("a standing rule per repository", () => { | |
| 33 | let dataDir = ""; | |
| 34 | let app: App; | |
| 35 | let servers: Servers; | |
| 36 | let alice: User; | |
| 37 | let repo: Repo; | |
| 38 | let cookie = ""; | |
| 39 | let calls = 0; | |
| 40 | let says = 0.9; | |
| 41 | ||
| 42 | const standIn: Decider = { | |
| 43 | name: "stand-in", | |
| 44 | async decide(_state, questions) { | |
| 45 | calls++; | |
| 46 | const out: Record<string, any> = {}; | |
| 47 | for (const [key, q] of Object.entries(questions)) { | |
| 48 | const [a, b] = q.options as [string, string]; | |
| 49 | out[key] = { choice: a, p: says, probabilities: { [a]: says, [b]: 1 - says } }; | |
| 50 | } | |
| 51 | return out as any; | |
| 52 | }, | |
| 53 | }; | |
| 54 | ||
| 55 | const rule = (by: "me" | "decider" | "decider_tell", autonomy: "off" | "assisted" = "assisted") => { | |
| 56 | repo = updateRepoSettings(app.db, repo, { decisionsBy: by, autonomy }); | |
| 57 | }; | |
| 58 | const parked = (title: string): Issue => { | |
| 59 | const made = openIssue(app, alice, repo, { title }); | |
| 60 | if (!made.ok) throw new Error(String(made.error)); | |
| 61 | expect(addComment(app, alice, repo, made.value, BREAKER_NOTE).ok).toBe(true); | |
| 62 | return made.value; | |
| 63 | }; | |
| 64 | const asking = (issue: Issue): boolean => | |
| 65 | askLinesFor(app.db, alice).some((r) => r.items.includes(`parked:${issue.id}`)); | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-standing-")); | |
| 69 | app = createApp({ dataDir, port: 0 }); | |
| 70 | servers = await serve(app); | |
| 71 | const made = await createUser(app.db, { | |
| 72 | trusted: true, | |
| 73 | username: "alice", | |
| 74 | email: "[email protected]", | |
| 75 | password: "a-long-standing-passphrase", | |
| 76 | }); | |
| 77 | if (!made.ok) throw new Error("no alice"); | |
| 78 | const signin = await fetch(`${servers.url}/signin`, { | |
| 79 | method: "POST", | |
| 80 | body: new URLSearchParams({ username: "alice", password: "a-long-standing-passphrase" }), | |
| 81 | redirect: "manual", | |
| 82 | }); | |
| 83 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 84 | alice = getUserByName(app.db, "alice")!; | |
| 85 | const r = await createRepo(app, alice, { name: "shelf", visibility: "public" }); | |
| 86 | if (!r.ok) throw new Error(String(r.error)); | |
| 87 | repo = r.value; | |
| 88 | app.config.decider = standIn; | |
| 89 | }, 30_000); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | await servers.stop(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a repository ruled me is left alone", async () => { | |
| 97 | rule("me"); | |
| 98 | const issue = parked("mine"); | |
| 99 | const before = calls; | |
| 100 | await sweepStanding(app); | |
| 101 | expect(asking(issue)).toBe(true); | |
| 102 | expect(calls).toBe(before); | |
| 103 | }); | |
| 104 | ||
| 105 | test("ruled decider, a hand-over is answered without a person, once", async () => { | |
| 106 | rule("decider"); | |
| 107 | says = 0.9; | |
| 108 | const issue = parked("old idea"); | |
| 109 | // The card left over from the "me" test is the rule's too, now. | |
| 110 | const swept = await sweepStanding(app); | |
| 111 | expect(swept.byDecider).toBe(2); | |
| 112 | expect(asking(issue)).toBe(false); | |
| 113 | const row = listDecisions(app.db, repo.id, { kind: "hand" }).find((d) => d.issueId === issue.id)!; | |
| 114 | expect(row.by).toBe("stand-in"); | |
| 115 | expect(row.outcome).toBe("set_aside"); | |
| 116 | const after = calls; | |
| 117 | await sweepStanding(app); | |
| 118 | expect(calls).toBe(after); | |
| 119 | }); | |
| 120 | ||
| 121 | test("decider_tell leaves an unsure one with the owner; decider's word stands", async () => { | |
| 122 | says = 0.55; | |
| 123 | rule("decider_tell"); | |
| 124 | const unsure = parked("hard call"); | |
| 125 | await sweepStanding(app); | |
| 126 | expect(asking(unsure)).toBe(true); | |
| 127 | const row = listDecisions(app.db, repo.id, { kind: "hand" }).find((d) => d.issueId === unsure.id)!; | |
| 128 | expect(row.outcome).toBe("asked"); | |
| 129 | ||
| 130 | rule("decider"); | |
| 131 | const standing = parked("settled"); | |
| 132 | await sweepStanding(app); | |
| 133 | expect(asking(standing)).toBe(false); | |
| 134 | // The unsure one was already answered: it is not asked again. | |
| 135 | expect(asking(unsure)).toBe(true); | |
| 136 | says = 0.9; | |
| 137 | }); | |
| 138 | ||
| 139 | test("a shelved repository's hand-over closes with no model call, and undo restores it", async () => { | |
| 140 | rule("decider_tell", "off"); | |
| 141 | const issue = parked("shelved"); | |
| 142 | const before = calls; | |
| 143 | const swept = await sweepStanding(app); | |
| 144 | expect(swept.byRule).toBeGreaterThanOrEqual(1); | |
| 145 | expect(calls).toBe(before); | |
| 146 | expect(asking(issue)).toBe(false); | |
| 147 | const row = listDecisions(app.db, repo.id, { kind: "hand" }).find((d) => d.issueId === issue.id)!; | |
| 148 | expect(row.by).toBe("rule"); | |
| 149 | expect((row.state as any).evidence.title).toBe("shelved"); | |
| 150 | expect(undoHandled(app, alice, row.id)).toMatchObject({ outcome: "undone" }); | |
| 151 | expect(asking(issue)).toBe(true); | |
| 152 | await sweepStanding(app); | |
| 153 | expect(asking(issue)).toBe(true); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the settings form saves the rule and refuses a made-up one", async () => { | |
| 157 | const post = (decisions: string) => | |
| 158 | fetch(`${servers.url}/alice/shelf/settings/autonomy`, { | |
| 159 | method: "POST", | |
| 160 | headers: { cookie, origin: servers.url }, | |
| 161 | body: new URLSearchParams({ autonomy: "assisted", ceiling: "20", slots: "1", direction: "", decisions }), | |
| 162 | redirect: "manual", | |
| 163 | }); | |
| 164 | expect((await post("decider")).status).toBe(303); | |
| 165 | expect(getRepoById(app.db, repo.id)!.decisionsBy).toBe("decider"); | |
| 166 | await post("nobody"); | |
| 167 | expect(getRepoById(app.db, repo.id)!.decisionsBy).toBe("decider"); | |
| 168 | const page = await (await fetch(`${servers.url}/alice/shelf/settings`, { headers: { cookie } })).text(); | |
| 169 | expect(page).toContain("Decisions here"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a manual tap follows the repository's rule, as the sweep does", async () => { | |
| 173 | const tap = async (title: string) => { | |
| 174 | const issue = parked(title); | |
| 175 | const done = await decideAsk(app, alice, `parked:${issue.id}`); | |
| 176 | expect(done.ok).toBe(true); | |
| 177 | return issue; | |
| 178 | }; | |
| 179 | says = 0.6; | |
| 180 | rule("decider_tell"); | |
| 181 | expect(asking(await tap("tap cautious"))).toBe(true); | |
| 182 | rule("decider"); | |
| 183 | expect(asking(await tap("tap ruled"))).toBe(false); | |
| 184 | }); | |
| 185 | ||
| 186 | test("on an organisation's repository the rule closes the card for its person admin", async () => { | |
| 187 | const org = createOrg(app.db, "acme", alice.id); | |
| 188 | if (!org.ok) throw new Error("no org"); | |
| 189 | const r = await createRepo(app, alice, { owner: "acme", name: "svc", visibility: "public" }); | |
| 190 | if (!r.ok) throw new Error(String(r.error)); | |
| 191 | const orgRepo = updateRepoSettings(app.db, r.value, { decisionsBy: "decider", autonomy: "assisted" }); | |
| 192 | const made = openIssue(app, alice, orgRepo, { title: "org idea" }); | |
| 193 | if (!made.ok) throw new Error(String(made.error)); | |
| 194 | expect(addComment(app, alice, orgRepo, made.value, BREAKER_NOTE).ok).toBe(true); | |
| 195 | const item = `parked:${made.value.id}`; | |
| 196 | expect(askLinesFor(app.db, alice).some((l) => l.items.includes(item))).toBe(true); | |
| 197 | says = 0.9; | |
| 198 | await sweepStanding(app); | |
| 199 | expect(askLinesFor(app.db, alice).some((l) => l.items.includes(item))).toBe(false); | |
| 200 | const row = listDecisions(app.db, orgRepo.id, { kind: "hand" }).find((d) => d.issueId === made.value.id)!; | |
| 201 | expect(row.outcome).toBe("set_aside"); | |
| 202 | expect((row.state as any).actor).toBe("standing"); | |
| 203 | // Any admin takes it back, and it asks again. | |
| 204 | expect(undoHandled(app, alice, row.id)).toMatchObject({ outcome: "undone" }); | |
| 205 | expect(askLinesFor(app.db, alice).some((l) => l.items.includes(item))).toBe(true); | |
| 206 | }); | |
| 207 | ||
| 208 | test("a dismissal from an earlier park does not shield a later one", async () => { | |
| 209 | rule("decider"); | |
| 210 | says = 0.9; | |
| 211 | const issue = parked("twice"); | |
| 212 | recordInteraction(app.db, { | |
| 213 | viewerId: alice.id, | |
| 214 | itemId: `ask:parked:${issue.id}`, | |
| 215 | kind: "dismissed", | |
| 216 | repoId: repo.id, | |
| 217 | actorId: alice.id, | |
| 218 | }); | |
| 219 | await Bun.sleep(5); | |
| 220 | expect(addComment(app, alice, repo, issue, BREAKER_NOTE).ok).toBe(true); | |
| 221 | const before = calls; | |
| 222 | await sweepStanding(app); | |
| 223 | expect(calls).toBe(before + 1); | |
| 224 | }); | |
| 225 | ||
| 226 | test("a tick on a card answered a moment ago does not pay again", async () => { | |
| 227 | rule("decider"); | |
| 228 | const issue = parked("tap then tick"); | |
| 229 | await decideAsk(app, alice, `parked:${issue.id}`); | |
| 230 | // A newer park inside the ten minutes waits for the next tick. | |
| 231 | expect(addComment(app, alice, repo, issue, BREAKER_NOTE).ok).toBe(true); | |
| 232 | const before = calls; | |
| 233 | await sweepStanding(app); | |
| 234 | expect(calls).toBe(before); | |
| 235 | }); | |
| 236 | }); |
Both callers pass
repo.decisionsBy, so this default is never used. Makemoderequired; a silentdecider_tellfallback could hide a caller that forgot the rule.