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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1605-a-standing-rule-per-repository-decisions → main 10 commits fork 9 threads · 9 unresolved

  • Merged. scout/1605-a-standing-rule-per-repository-decisions was merged into main as e77cad1.

Review

Sign in to review this change.
19 files changed +854 −39
README.md +18
@@ -282,6 +282,24 @@ configured leaves it with you. Undo for a day (`/api/v1/asks/decide/<id>/undo`,
282282 `asks_undecide`, `U`). Each tap is a paid call, so one card is handed over
283283 once in ten minutes.
284284
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
285303 ## On your machine
286304
287305 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.
3838 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
3939 | `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`) | |
4040 | `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 |
4142 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4243 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4344 | `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[] = [
167167 "test/review-wait.test.ts",
168168 "test/asks-bulk.test.ts",
169169 "test/hand-decider.test.ts",
170 "test/decisions-by-doors.test.ts",
171 "test/standing-rule.test.ts",
170172 "test/asks-expiry.test.ts",
171173 "test/asks-rank.test.ts",
172174 "test/plan-pillars.test.ts",
src/actions/asks_decide.ts +7 −18
@@ -11,13 +11,12 @@ import { getDecision, latestDecision } from "../core/decisions.ts";
1111 import { getIssueById } from "../core/issues.ts";
1212 import { getRepoById } from "../core/repos.ts";
1313 import type { User } from "../core/users.ts";
14 import { letDeciderHandle, undoHandled } from "../crew/handover.ts";
14 import { guardedHand, letDeciderHandle, undoHandled } from "../crew/handover.ts";
1515 import { err, ok, type Result } from "../kernel/result.ts";
1616 import { askTargetsFor } from "../web/studio/asks.ts";
1717 import type { FleetError } from "./fleet.ts";
1818
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";
2120
2221 /** What the decider did with a card: plain data for any door. */
2322 export interface HandAnswer {
@@ -35,9 +34,6 @@ export interface HandAnswer {
3534 const fail = (code: FleetError["code"], message: string) =>
3635 err<FleetError>({ code, message });
3736
38 /** Cards whose model call is in flight: a second tap must not pay again. */
39 const handing = new Set<string>();
40
4137 const ASK_ID = /^(parked|issue):[A-Za-z0-9_-]{1,64}$/;
4238
4339 function describe(ask: string, d: NonNullable<ReturnType<typeof getDecision>>): HandAnswer {
@@ -73,22 +69,15 @@ export async function decideAsk(
7369 const repo = issue === undefined ? undefined : getRepoById(app.db, issue.repoId);
7470 if (issue === undefined || repo === undefined)
7571 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")
8176 return fail(
8277 "too_many",
8378 "the decider answered this a moment ago; try again in a few minutes",
8479 );
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));
9281 }
9382
9483 /** Put a set-aside card back on the board, within a day. */
src/actions/repos.ts +38 −1
@@ -23,6 +23,7 @@ import {
2323 getSealedFork,
2424 updateRepoSettings as writeSettings,
2525 type CreateRepoError,
26 type DecisionsBy,
2627 type ForkRepoError,
2728 type Repo,
2829 type RepoSettings,
@@ -246,7 +247,11 @@ export async function updateRepoSettings(
246247 if (!admin.ok) return admin;
247248 // Who the crew obeys is a person's call: an agent never widens it, even
248249 // 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 )
250255 return err("not_found");
251256 // The work on a sealed report stays private until it merges into its
252257 // base, whoever owns it (`core/sealed.ts`).
@@ -270,6 +275,7 @@ export async function updateRepoSettings(
270275 "requireReview",
271276 "requireChecks",
272277 "crewOutsiders",
278 "decisionsBy",
273279 ] as const
274280 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
275281 app.bus.append({
@@ -321,6 +327,37 @@ export async function setCrewOutsiders(
321327 return ok({ on: updated.value.crewOutsiders, was: repo.crewOutsiders, dryRun });
322328 }
323329
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
324361 export async function deleteRepo(
325362 app: App,
326363 actor: User | undefined,
src/core/repos.ts +13 −1
@@ -116,6 +116,8 @@ export interface Repo {
116116 * default; on a public repository it hands strangers the owner's budget.
117117 */
118118 readonly crewOutsiders: boolean;
119 /** The standing rule for decisions here: `me`, `decider`, or `decider_tell`. */
120 readonly decisionsBy: DecisionsBy;
119121 /** The issue the planner is given, when the map has run dry. */
120122 readonly directionIssueId: string | null;
121123 /** The repo this was forked from, or null. */
@@ -160,6 +162,7 @@ interface RepoRow {
160162 autonomy_slots: number;
161163 autonomy_by: string | null;
162164 crew_outsiders: number;
165 decisions_by: string;
163166 direction_issue_id: string | null;
164167 fork_parent_id: string | null;
165168 sealed_for: string | null;
@@ -197,6 +200,7 @@ function toRepo(row: RepoRow): Repo {
197200 autonomySlots: row.autonomy_slots,
198201 autonomyBy: row.autonomy_by,
199202 crewOutsiders: row.crew_outsiders === 1,
203 decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me",
200204 directionIssueId: row.direction_issue_id,
201205 forkParentId: row.fork_parent_id,
202206 sealedFor: row.sealed_for,
@@ -1007,6 +1011,12 @@ export function canAdmin(
10071011 return accessAllows(orgAccessFor(db, repo, user), "admin");
10081012 }
10091013
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
10101020 export interface RepoSettings {
10111021 visibility?: "public" | "private";
10121022 description?: string;
@@ -1025,6 +1035,7 @@ export interface RepoSettings {
10251035 autonomyBy?: string | null;
10261036 /** Whether someone who cannot write here may start or steer a run. */
10271037 crewOutsiders?: boolean;
1038 decisionsBy?: DecisionsBy;
10281039 }
10291040
10301041 /** Update the mutable repo fields, leaving unspecified ones untouched. */
@@ -1037,7 +1048,7 @@ export function updateRepoSettings(
10371048 `UPDATE repo SET visibility = ?1, description = ?2, protect_default = ?3,
10381049 default_branch = ?4, require_review = ?5, require_checks = ?7,
10391050 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
10411052 WHERE id = ?6`,
10421053 ).run(
10431054 settings.visibility ?? repo.visibility,
@@ -1053,6 +1064,7 @@ export function updateRepoSettings(
10531064 settings.autonomySlots ?? repo.autonomySlots,
10541065 settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy,
10551066 (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0,
1067 settings.decisionsBy ?? repo.decisionsBy,
10561068 );
10571069 return getRepoById(db, repo.id)!;
10581070 }
src/core/schema.ts +6
@@ -1735,6 +1735,12 @@ export const migrations: readonly string[] = [
17351735 placed_at TEXT NOT NULL
17361736 );
17371737 `,
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 `,
17381744 ];
17391745
17401746 /**
src/crew/handover.ts +99 −14
@@ -16,6 +16,7 @@ import type { App } from "../app.ts";
1616 import { recentEnds } from "../core/ask_ledger.ts";
1717 import {
1818 getDecision,
19 latestDecision,
1920 recordDecision,
2021 setOutcome,
2122 type Decision,
@@ -28,7 +29,7 @@ import {
2829 } from "../core/interactions.ts";
2930 import type { Issue } from "../core/issues.ts";
3031 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";
3233 import type { User } from "../core/users.ts";
3334 import { spentTodayForRepos } from "./autonomy.ts";
3435 import { certain, decideOr, type Answers, type Questions } from "./decide.ts";
@@ -40,6 +41,36 @@ export const HAND_UNDO_MS = 86_400_000;
4041 /** The least confidence at which an answer takes a card off the board. */
4142 export const SURE = 0.7;
4243
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
4374 const SET_ASIDE = "set_aside";
4475 const ASK_OWNER = "ask_owner";
4576
@@ -102,6 +133,23 @@ export function handContext(
102133 };
103134 }
104135
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
105153 /** The dismissal item a parked card is set aside under (`askItemsOf`). */
106154 const itemOf = (issue: Issue): string => `ask:parked:${issue.id}`;
107155
@@ -120,6 +168,8 @@ export async function letDeciderHandle(
120168 owner: User,
121169 repo: Repo,
122170 issue: Issue,
171 mode: DecisionsBy,
outdated

Both callers pass repo.decisionsBy, so this default is never used. Make mode required; a silent decider_tell fallback could hide a caller that forgot the rule.

172 standing = false,
123173 ): Promise<Handled> {
124174 const context = handContext(app, owner, repo, issue);
125175 const rule = (): Answers<typeof QUESTIONS> => ({
@@ -132,10 +182,14 @@ export async function letDeciderHandle(
132182 rule,
133183 );
134184 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);
136188 const percent = Math.round(answer.p * 100);
137189 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.`
139193 : decided.by === "rule"
140194 ? "No decider answered, so it stays with you."
141195 : `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(
143197 kind: "hand",
144198 repoId: repo.id,
145199 issueId: issue.id,
146 state: { ...context, reason, actor: owner.id },
200 state: { ...context, reason, actor: standing ? STANDING : owner.id },
147201 answer: sure ? SET_ASIDE : ASK_OWNER,
148202 p: answer.p,
149203 by: decided.by,
150204 ms: decided.ms,
151205 });
152206 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);
160208 setOutcome(app.db, decision.id, "set_aside");
161209 } else setOutcome(app.db, decision.id, "asked");
162210 return { decision: getDecision(app.db, decision.id)!, setAside: sure };
@@ -183,10 +231,15 @@ export function undoHandled(
183231 // Someone else's set-aside is not found: only the dismissal's owner undoes it.
184232 const item = `ask:parked:${decision.issueId}`;
185233 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 set_aside now hides the card for every admin of the repository, because notDismissed checks the ledger outcome. Only the person who tapped can undo it, since this line requires actor === owner.id. On an org repository, a second admin loses the card and has no way to bring it back. Either let any admin undo a tap's set-aside, the way the standing rule's already can, or keep a tap's effect per viewer. Whichever you choose, add a test with two admins.

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";
188241 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]);
190243 setOutcome(app.db, decision.id, "undone");
191244 return getDecision(app.db, decision.id)!;
192245 }
@@ -206,3 +259,35 @@ export function forReader(d: Decision, reader: User | undefined): Decision {
206259 state: { ...rest, repository: { name: repository?.name } },
207260 };
208261 }
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 (crew/standing.ts) passes true. Drop it and always record STANDING.

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 standing is always true here. Remove it along with the parameter.

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 repo.ownerId is the org account. The decision, its actor and the 'dismissed' interaction all land on the org's id, while the card stays on every person admin's board. Because the ledger now has a decision newer than the park, the sweep never comes back to it. Close the ask for everyone it is shown to, and test this 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

.has ignores when the dismissal happened, so a dismissal from an earlier park blocks every later one. Compare the dismissal time with p.at, as asks.ts:495 does.

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 handing guard and the once-in-ten-minutes throttle that decideAsk applies, so a tap racing a tick pays twice. Share the guard.

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";
143143 import {
144144 forkParentFor,
145145 getReposByIds,
146 isDecisionsBy,
146147 } from "../core/repos.ts";
147148 import {
148149 commentCounts,
@@ -1732,6 +1733,12 @@ export function apiRoutes(router: Router<Handler>): void {
17321733 const repo = loadRepo(ctx);
17331734 if (!repo) return notFound();
17341735 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 );
17351742 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
17361743 ...(input.visibility === "public" || input.visibility === "private"
17371744 ? { visibility: input.visibility }
@@ -1755,6 +1762,10 @@ export function apiRoutes(router: Router<Handler>): void {
17551762 ...(typeof input.crewOutsiders === "boolean"
17561763 ? { crewOutsiders: input.crewOutsiders }
17571764 : {}),
1765 // The standing rule for its decisions: also an admin's, never an agent's.
1766 ...(isDecisionsBy(input.decisionsBy)
1767 ? { decisionsBy: input.decisionsBy }
1768 : {}),
17581769 });
17591770 if (!updated.ok) return respondAccess(updated.error, 422);
17601771 return json(repoJson(ctx, updated.value));
src/http/mcp.ts +41 −1
@@ -90,6 +90,7 @@ import {
9090 canWrite,
9191 forkParentFor,
9292 getRepo,
93 isDecisionsBy,
9394 ownerFactRepoIds,
9495 type Repo,
9596 } from "../core/repos.ts";
@@ -106,7 +107,7 @@ import {
106107 directionNote,
107108 OUTSIDERS_NOTE,
108109 } from "../crew/direct.ts";
109 import { setCrewOutsiders } from "../actions/repos.ts";
110 import { setCrewOutsiders, setDecisionsBy } from "../actions/repos.ts";
110111 import { mapJson, marksFor } from "../web/pages/map.ts";
111112 import { undoFleetOp } from "../actions/fleet.ts";
112113 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
@@ -873,6 +874,45 @@ const TOOLS: readonly Tool[] = [
873874 });
874875 },
875876 },
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 },
876916 {
877917 name: "probe",
878918 description:
src/main.ts +7
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { sweepStanding } from "./crew/standing.ts";
78 import { handover } from "./release/swap.ts";
89 import { parentUrlProblem } from "./core/parent_releases.ts";
910 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
364365 // expired: what asked each person, and how it ended.
365366 const tickAsks = async (): Promise<void> => {
366367 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 }
367374 try {
368375 recordAskLedger(app.db);
369376 } catch (error) {
src/tui/screens/workspace.ts +21 −1
@@ -39,7 +39,8 @@ import { err, ok } from "../../kernel/result.ts";
3939 import { createBranch } from "../../actions/branches.ts";
4040 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
4141 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";
4344 import type { TreeEntry } from "../../git/read.ts";
4445 import type { RepoFacts, RepoSource } from "../../view/source.ts";
4546 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
@@ -1283,6 +1284,9 @@ export function workspaceScreen(
12831284 binding(["y"], "copy path"),
12841285 binding(["i", "p", "C"], "issues / pulls / commits"),
12851286 binding(["I"], "about this repository"),
1287 ...(repo !== undefined && admin()
1288 ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)]
1289 : []),
12861290 ...liveBindings(src),
12871291 ...(liveSide?.mirroredAs === undefined
12881292 ? []
@@ -1670,6 +1674,22 @@ export function workspaceScreen(
16701674 );
16711675 return true;
16721676 }
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 }
16731693 if (plain && e.key === "r") {
16741694 cache.clear();
16751695 runAsync(ctx, reload);
src/web/pages/settings.ts +10 −1
@@ -39,7 +39,7 @@ import {
3939 } from "../../core/app_env.ts";
4040 import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts";
4141 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
42 import { isAutonomy } from "../../core/repos.ts";
42 import { isAutonomy, isDecisionsBy } from "../../core/repos.ts";
4343 import {
4444 createLabel,
4545 createWebhook,
@@ -1759,12 +1759,21 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> {
17591759 message:
17601760 "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 1 to 8.",
17611761 });
1762 const rule = field(form, "decisions");
1763 const decisionsBy = rule === "" ? repo.decisionsBy : rule;

This always sends decisionsBy, so the new agent refusal in actions/repos.ts (!== undefined) blocks an agent admin from saving any autonomy field here. Send it only when it differs from repo.decisionsBy.

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 });
17621769 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
17631770 autonomy: mode,
17641771 autonomyDirection: field(form, "direction").trim().slice(0, 2000),
17651772 autonomyCeilingUsd: ceiling,
17661773 autonomySlots: slots,
17671774 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 }),
17681777 });
17691778 return flashTo(ctx, `${base}/settings#autonomy`, {
17701779 ...(updated.ok
src/web/studio/asks.ts +11 −2
@@ -490,12 +490,21 @@ function notDismissed<T extends AskCandidate>(
490490 ): T[] {
491491 const muted = mutedRepos(db, user.id);
492492 const dismissed = askDismissals(db, user.id);
493 if (dismissed.size === 0 && muted.size === 0) return [...asks];
494493 const holds = (id: string, since: string): boolean =>
495494 (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 };
496502 return asks.filter(
497503 (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),
499508 );
500509 }
501510
src/web/studio/crew.ts +18
@@ -326,6 +326,24 @@ ${repo.autonomyDirection}</textarea
326326 map is only what people write.`}
327327 </p>
328328 </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 })}
329347 ${field({
330348 label: "Ceiling per day",
331349 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

listEvents is never used. The same file also has a no-op getRepoById(app.db, repo.id); at the start of the terminal test.

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", () => {
175175 "health",
176176 "audit",
177177 "outsiders",
178 "decisions_by",
178179 "probe",
179180 "following",
180181 "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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs