Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

The plan reads itself: Triage suggestions, progress in Now, and letting go (Closes #206) #207

Mergedsyntaqx merged plan-phases into main
28 files+1469−420/28 viewed
+1−1
13961396
13971397 [[package]]
13981398 name = "g1t"
1399−version = "0.4.2"
1399+version = "0.4.3"
14001400 dependencies = [
14011401 "anstyle-query",
14021402 "base64 0.22.1",
+0−0

Binary or large file; its contents are not shown.

+4−0
1919 answer with the provider and model chosen under **Ask g1t**.
2020 - **Sessions on work items** start on the workspace's Anthropic provider. See
2121 [Start a session on your Anthropic account](/guides/sessions/#start-a-session-on-your-anthropic-account).
22+- **[The plan](/work/plan/)** asks the provider chosen under **Ask g1t**, on
23+ its fast model, where each new work item in Triage goes, and drafts where
24+ each work item in Now stands. A few short questions every few minutes,
25+ only when something is new.
2226
2327 Only workspace owners add, test, change and remove providers, and choose
2428 what Ask uses. Members see which providers there are and what Ask answers
+1−1
126126 | Code | A failed production build; your open pull request that can't land until you act (it conflicts, a required check fails, someone asked for changes, approvals are missing); your pull request that meets everything its branch asks for, ready for you to merge; a review asked of you by name; an invitation to a repository; a job waiting for a self-hosted runner |
127127 | Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure. One whose cause is over, such as a workflow that has passed since or a pull request that merged, has [settled](/guides/notifications/#threads-that-settle-themselves) and is not here |
128128 | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out |
129−| Work | A work item in [the plan](/work/plan/)'s Now that is assigned to you or that you claimed, in Now's order |
129+| Work | A work item in [the plan](/work/plan/)'s Now that is assigned to you or that you claimed, in Now's order; for those who can plan, work items that have waited in Triage more than a week |
130130
131131 A notification about something already on the list, such as a pull
132132 request whose row is there, is left out.
+54−1
4545 in a place. The move is noted in its conversation ("moved this from Next
4646 to Now").
4747
48+### Suggestions in Triage
49+
50+When the workspace has an [AI provider](/guides/ai/) chosen for Ask, g1t
51+suggests where each new work item in Triage goes: a horizon (Now, Next or
52+Later), an appetite, an open work item it seems to repeat, and why, in a
53+sentence. g1t asks the workspace's own provider once for each new work
54+item, within a few minutes of it being filed. Nothing moves until someone
55+accepts:
56+
57+- **Accept** moves it as suggested: last on that horizon, with that
58+ appetite.
59+- **Close as duplicate**, when it seems to repeat another, closes it as
60+ not planned with a comment naming the other.
61+
62+A workspace with no provider gets no suggestions; Triage works the same
63+without them. When work items have waited in Triage more than a week,
64+Home tells those who can plan.
65+
4866 ### Who can plan
4967
5068 | To | You need |
85103 ahead of everything ranked there, and stays there until it closes.
86104 Reopening a work item clears it.
87105
106+## How work in Now stands
107+
108+Nobody reports progress by hand. Each work item in Now shows where it is
109+on its **hill**, from what its record says:
110+
111+| Stage | When |
112+| ------------------- | --------------------------------------------------------------------------- |
113+| **Figuring it out** | Nothing is decided yet: sessions on it, or nothing at all. |
114+| **Decided** | A decision is recorded on it, so the approach is settled. |
115+| **Building** | It has a draft pull request, or a session on it made commits. |
116+| **Landing** | A pull request for it is in review or merged, or it landed in some targets. |
117+
118+Under its title, a sentence or two says **where it stands** and what comes
119+next. g1t drafts it from the work item's sessions, decisions and pull
120+requests with the workspace's AI provider, again whenever something new
121+happens on it. A work item with nothing happening on it for 5 days says
122+it has gone **quiet**.
123+
124+**This week**, beside the plan, lists what landed from Now in the last 7
125+days and how many in Now have gone quiet.
126+
127+## Letting go
128+
129+Later is for work you want, not for everything anyone ever filed. A work
130+item in Later that nothing has happened to for 90 days is set aside, and
131+its conversation says so ("set this aside: nothing happened on it in
132+Later for 90 days"). It is not deleted: search finds it, and one move puts
133+it back. The plan says how many were set aside in the last 30 days.
134+
88135 ## What to take next
89136
90137 The work item to take next is the first one in **Now** that nobody is
122169 | List one horizon | [`list_work_items`](/reference/api/work-items/list-work-items/) with `horizon`, in the plan's order |
123170 | Set Now's cap | [`configure_plan`](/reference/api/work-items/configure-plan/) |
124171
172+`get_plan` also answers `progress` (each Now work item's `hill`,
173+`active_at`, `quiet` and drafted `standing`), `landed` (what closed from
174+Now in the last 7 days), `triage_waiting` and `set_aside_recent`. A work
175+item in Triage carries its `suggestion`.
176+
125177 Each move publishes `work_item.planned`, with the work item's `horizon`,
126178 `fromHorizon` when it changed horizon, its `appetite` and whether it is
127−`urgent`. See [webhooks](/guides/webhooks/).
179+`urgent`. Setting aside after 90 days publishes it too, with no actor.
180+See [webhooks](/guides/webhooks/).
+0−0

Binary or large file; its contents are not shown.

+2−0
1414 GitCommitHorizontal,
1515 GitMerge,
1616 GitPullRequest,
17+ Inbox,
1718 ListTodo,
1819 Loader,
1920 Mail,
161162 notification: <Bell size={16} className="text-warn" />,
162163 chat: <AtSign size={16} className="text-accent" />,
163164 now: <Target size={16} className="text-accent" />,
165+ triage: <Inbox size={16} className="text-warn" />,
164166 next: <ListTodo size={16} className="text-accent" />,
165167 };
166168
+52−7
11 import { Link, useFetcher } from "react-router";
22 import { useEffect, useId, useState } from "react";
33
4−import { APPETITES, HORIZONS, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts";
4+import { Check, Sparkles } from "lucide-react";
5+
6+import { APPETITES, HORIZONS, type Horizon, type Progress, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts";
57
6−import { AppetiteChip } from "./plan";
8+import { AppetiteChip, HillMark, QuietBadge, StandingText } from "./plan";
79 import { ErrorText } from "./ui";
810 import { Input, InputAddon, InputGroup } from "./ui/input";
911 import { Badge } from "./ui/badge";
1113 import { SelectField } from "./ui/select";
1214 import { Switch } from "./ui/switch";
1315 import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
16+import { Card } from "./ui/card";
1417 import { Hint } from "./ui/hint";
15−import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, budgetText, planPath } from "../lib/plan";
18+import { cn } from "../lib/cn";
19+import { itemPath } from "../lib/work-items";
20+import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, acceptForm, budgetText, canAccept, planPath, suggestionText } from "../lib/plan";
1621
1722 /**
1823 * The item page's Plan, in its sidebar beside Milestone: where it stands
2025 * working on it, and urgent, which puts it first in Now. Each change posts
2126 * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the
2227 * Triage role); Now and urgent also take the right to manage milestones
23− * (`canNow`), which the work service checks again.
28+ * (`canNow`), which the work service checks again. In Triage, where g1t
29+ * suggests it goes, with Accept; in Now, how it stands (`progress`): its
30+ * hill and where it stands, as the plan reads them.
2431 */
2532 export function PlanBox({
2633 item,
2734 workspace,
2835 canEdit,
2936 canNow,
37+ progress,
3038 }: {
31− item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state">;
39+ item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state" | "suggestion">;
3240 workspace: string;
3341 canEdit: boolean;
3442 canNow: boolean;
43+ /** How it stands, while it is in Now; null when it could not be read. */
44+ progress?: Progress | null;
3545 }) {
3646 const fetcher = useFetcher<{ error?: string } | null>();
3747 const budgetId = useId();
4555 useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]);
4656 const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" });
4757 const lockedInNow = horizon === "now" && !canNow;
58+ const suggestion = item.horizon === "triage" && horizon === "triage" && item.state === "open" ? (item.suggestion ?? null) : null;
59+ const standing = item.horizon === "now" && horizon === "now" && item.state === "open" ? (progress ?? null) : null;
4860 return (
4961 <section>
5062 <div className="flex items-center justify-between gap-2">
5365 Open the plan
5466 </Link>
5567 </div>
68+ {suggestion && (
69+ <Card tone="plain" radius="lg" className="mt-2 space-y-1.5 border-accent/20 bg-accent/5 px-3 py-2 text-xs">
70+ <p className="flex items-center gap-1.5">
71+ <Sparkles size={12} className="shrink-0 text-accent" aria-hidden />
72+ <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span>
73+ </p>
74+ {suggestion.duplicateOf != null && (
75+ <p className="text-muted">
76+ Looks like{" "}
77+ <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline">
78+ #{suggestion.duplicateOf}
79+ </Link>
80+ </p>
81+ )}
82+ <p className="text-muted">{suggestion.why}</p>
83+ {editable && canAccept(suggestion, canNow) && (
84+ <Button size="xs" variant="accent" disabled={fetcher.state !== "idle"} onClick={() => post(acceptForm(suggestion))}>
85+ <Check size={13} />
86+ Accept
87+ </Button>
88+ )}
89+ </Card>
90+ )}
91+ {standing && (
92+ <div className="mt-2 space-y-1.5">
93+ <div className="flex flex-wrap items-center gap-2">
94+ <HillMark hill={standing.hill} />
95+ <span className="text-xs text-muted">{hillLabel(standing.hill)}</span>
96+ <QuietBadge progress={standing} />
97+ </div>
98+ <StandingText standing={standing} />
99+ </div>
100+ )}
56101 {editable ? (
57− <div className="mt-2 space-y-3">
102+ <div className={cn("space-y-3", suggestion || standing ? "mt-3" : "mt-2")}>
58103 <SelectField
59104 aria-label="Horizon"
60105 size="sm"
110155 {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>}
111156 </div>
112157 ) : (
113− <div className="mt-2 flex flex-wrap items-center gap-1.5 text-sm">
158+ <div className={cn("flex flex-wrap items-center gap-1.5 text-sm", suggestion || standing ? "mt-3" : "mt-2")}>
114159 {item.urgent && <Badge tone="danger">Urgent</Badge>}
115160 <span>{horizonLabel(item.horizon)}</span>
116161 {item.appetite && <AppetiteChip appetite={item.appetite} />}
+101−5
1−import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal } from "lucide-react";
1+import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal, Sparkles } from "lucide-react";
22 import type { DragEvent, ReactNode } from "react";
33 import { Link } from "react-router";
44
5−import { type Appetite, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts";
5+import { type Appetite, type Hill, type Horizon, type Progress as Standing, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts";
66
77 import { HarnessMark, StateDot } from "./sessions";
88 import { RepoChips } from "./work-items";
1313 import { Hint } from "./ui/hint";
1414 import { Progress } from "./ui/progress";
1515 import { cn } from "../lib/cn";
16−import { APPETITE_HINTS, type CapState, type Move, type OnIt, type Position, menuHorizons, moveDown, moveUp, waitingLabel } from "../lib/plan";
16+import {
17+ APPETITE_HINTS,
18+ type CapState,
19+ HILL_STEPS,
20+ type Move,
21+ type OnIt,
22+ type Position,
23+ hillStep,
24+ menuHorizons,
25+ moveDown,
26+ moveUp,
27+ quietText,
28+ waitingLabel,
29+} from "../lib/plan";
1730 import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions";
31+import { timeAgo } from "../lib/time-ago";
1832 import { itemPath } from "../lib/work-items";
1933
2034 /** A work item's appetite as its letter in a small square, with what it means on hover. */
2842 );
2943 }
3044
45+/** Where each of a hill's four stages sits on its curve, as x and y in a 44 by 16 box. */
46+const HILL_POINTS = HILL_STEPS.map((_, step) => {
47+ const along = (step + 0.5) / HILL_STEPS.length;
48+ return { x: 3 + along * 38, y: 14 - Math.sin(along * Math.PI) * 10 };
49+});
50+
51+/**
52+ * How far along a work item in Now is, as its record says: a small hill
53+ * with its four stages (figuring it out, decided, building, landing) and
54+ * the one it is at filled in. Its stage is its accessible name and its
55+ * hint; `label` also shows it in words, before the hill.
56+ */
57+export function HillMark({ hill, label = false }: { hill: Hill; label?: boolean }) {
58+ const at = hillStep(hill);
59+ const words = `${hillLabel(hill)}, stage ${at + 1} of ${HILL_STEPS.length}`;
60+ return (
61+ <Hint label={words}>
62+ <span role="img" aria-label={words} className="inline-flex shrink-0 items-center gap-1.5">
63+ {label && <span className="text-[0.6875rem] whitespace-nowrap text-faint">{hillLabel(hill)}</span>}
64+ <svg viewBox="0 0 44 16" className="h-4 w-11" aria-hidden>
65+ <path d="M2 15 Q 22 -5 42 15" fill="none" className="stroke-line-strong" strokeWidth="1.25" />
66+ {HILL_POINTS.map((point, step) =>
67+ step === at ? (
68+ <circle key={step} cx={point.x} cy={point.y} r="3" className="fill-accent" />
69+ ) : (
70+ <circle key={step} cx={point.x} cy={point.y} r="1.25" className={step < at ? "fill-accent/50" : "fill-line-strong"} />
71+ ),
72+ )}
73+ </svg>
74+ </span>
75+ </Hint>
76+ );
77+}
78+
79+/**
80+ * Where a work item in Now stands, in g1t's words from its record (its
81+ * sessions, decisions and pull requests), muted, with a small mark that
82+ * says g1t drafted it and when. Nothing until it has been drafted.
83+ */
84+export function StandingText({ standing, className }: { standing: Pick<Standing, "standing" | "standingAt">; className?: string }) {
85+ if (!standing.standing) return null;
86+ const when = standing.standingAt ? ` ${timeAgo(standing.standingAt)}` : "";
87+ return (
88+ <p className={cn("text-xs leading-relaxed text-muted", className)}>
89+ {standing.standing}
90+ <Hint label={<span suppressHydrationWarning>{`Drafted by g1t from its record${when}`}</span>}>
91+ <span className="ml-1 inline-flex translate-y-px items-center text-faint" aria-label="Drafted by g1t" role="img">
92+ <Sparkles size={11} />
93+ </span>
94+ </Hint>
95+ </p>
96+ );
97+}
98+
99+/** "Quiet for 6 days", for a work item in Now nothing has happened on lately. */
100+export function QuietBadge({ progress }: { progress: Pick<Standing, "quiet" | "activeAt"> }) {
101+ const text = quietText(progress, Date.now());
102+ if (!text) return null;
103+ return (
104+ <Badge tone="warn" suppressHydrationWarning>
105+ {text}
106+ </Badge>
107+ );
108+}
109+
31110 /** Who is on a work item: people's avatars, and the live sessions on it with what they run in, as the board shows them. */
32111 export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) {
33112 if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null;
168247 /**
169248 * One work item in the plan: its place (`rank`), urgent first, its number
170249 * and title, its repositories, what it waits on, who is on it, its
171− * appetite, and its menu.
250+ * appetite, and its menu. In Now, also how it stands (`progress`): its
251+ * hill, whether it has gone quiet, and where it stands under its title.
172252 */
173253 export function PlanRow({
174254 item,
178258 waiting,
179259 drag,
180260 menu,
261+ progress,
181262 }: {
182263 item: WorkItem;
183264 rank: number | null;
187268 waiting: readonly number[];
188269 drag?: RowDrag;
189270 menu?: ReactNode;
271+ progress?: Standing;
190272 }) {
273+ const quiet = progress?.quiet ?? false;
191274 return (
192275 <li
193276 draggable={drag?.draggable || undefined}
227310 <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span>
228311 {item.title}
229312 </Link>
230− {(item.repos.length > 0 || waiting.length > 0 || (on && (on.people.length > 0 || on.sessions.length > 0))) && (
313+ {progress && <StandingText standing={progress} className="mt-0.5 line-clamp-2" />}
314+ {(progress || item.repos.length > 0 || waiting.length > 0 || quiet || (on && (on.people.length > 0 || on.sessions.length > 0))) && (
231315 <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint">
316+ {/* On a phone the hill goes under the title, in words, where there is room for it. */}
317+ {progress && (
318+ <span className="sm:hidden">
319+ <HillMark hill={progress.hill} label />
320+ </span>
321+ )}
232322 <RepoChips repos={item.repos} workspace={workspace} max={1} />
323+ {progress && <QuietBadge progress={progress} />}
233324 {waiting.length > 0 && (
234325 <Badge tone="warn" className="max-w-full">
235326 <span className="truncate">{waitingLabel(waiting)}</span>
240331 )}
241332 </div>
242333 <div className="flex shrink-0 items-center gap-1">
334+ {progress && (
335+ <span className="mr-1 max-sm:hidden">
336+ <HillMark hill={progress.hill} />
337+ </span>
338+ )}
243339 {item.appetite && <AppetiteChip appetite={item.appetite} />}
244340 {menu}
245341 </div>
+40−0
4646 spendIn,
4747 startHere,
4848 summarizeQuestion,
49+ triageRow,
4950 waitingSentence,
5051 withSessions,
5152 withShipped,
239240 assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: null }).missing, ["Work"]);
240241 });
241242
243+test("work waiting in Triage over a week is a row for whoever plans, last, after their work in Now", () => {
244+ assert.equal(triageRow("acme", 0), null);
245+ const one = triageRow("acme", 1);
246+ assert.equal(one?.title, "1 work item has waited in Triage over a week");
247+ const row = triageRow("acme", 4);
248+ assert.equal(row?.title, "4 work items have waited in Triage over a week");
249+ assert.deepEqual(row?.action, { label: "Triage", to: "/acme/-/work/triage" });
250+ const mine = nowRows([workItem(7, { horizon: "now", assignees: ["bo"] })], [], "bo");
251+ const dm = { channel: { id: "c1", kind: "dm" as const, name: null, last_message_at: iso(NOW - DAY) }, title: "ana", muted: false, unread: 1, mentions: 0 };
252+ const out = attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [dm], now: mine, triageWaiting: 4 });
253+ assert.deepEqual(
254+ out.rows.map((r) => r.key),
255+ ["chat:c1", "item:acme#7", "triage:acme"],
256+ );
257+ // Alone, it is where to start, before the work item to take next.
258+ const alone = attention({
259+ slug: "acme",
260+ code: false,
261+ codeNeeds: null,
262+ notifications: [],
263+ chat: [],
264+ now: [],
265+ triageWaiting: 2,
266+ next: { item: workItem(31), viewer: "bo" },
267+ });
268+ // The work to take next comes before the nudge; with none, the nudge leads.
269+ assert.equal(alone.start?.row.key, nextRow(workItem(31)).key);
270+ assert.deepEqual(
271+ alone.rows.map((row) => row.key),
272+ ["triage:acme"],
273+ );
274+ const nudge = attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [], triageWaiting: 2 });
275+ assert.equal(nudge.start?.row.key, "triage:acme");
276+ assert.match(nudge.start?.why ?? "", /waited over a week to be placed/);
277+ // Nothing waiting that long, or a viewer who does not plan: no row.
278+ assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [], triageWaiting: 0 }).rows, []);
279+ assert.deepEqual(attention({ slug: "acme", code: false, codeNeeds: null, notifications: [], chat: [], now: [] }).rows, []);
280+});
281+
242282 test("the same order whatever order the rows came in", () => {
243283 const rows = [row({ key: "b", kind: "review", at: NOW }), row({ key: "a", kind: "review", at: NOW, action: { label: "Review", to: "/y" } })];
244284 assert.deepEqual(
+35−5
3030 import { HARNESS_NAMES } from "./coordination.ts";
3131 import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts";
3232 import { settledLine } from "./inbox.ts";
33−import { itemPath, sessionPath } from "./work-items.ts";
33+import { itemPath, sessionPath, workPath } from "./work-items.ts";
3434
3535 const MINUTE = 60_000;
3636 const HOUR = 60 * MINUTE;
10701070 * 3. a job waiting for a runner that is not there;
10711071 * 4. an unread notification that is a warning or a failure;
10721072 * 5. a mention or a direct message;
1073− * 6. a work item in the plan's Now that is yours: assigned to you, or claimed by you.
1073+ * 6. a work item in the plan's Now that is yours: assigned to you, or claimed by you;
1074+ * 7. work items that have waited in Triage over a week, for whoever plans.
10741075 *
10751076 * `next`, the work item to take next, is never a row: Start here falls back
10761077 * to it when nothing else ranks ([`attention`]).
10771078 */
1078−export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "ready" | "runner" | "notification" | "chat" | "now" | "next";
1079+export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "ready" | "runner" | "notification" | "chat" | "now" | "triage" | "next";
10791080
10801081 export const TIER: Record<AttentionKind, number> = {
10811082 deploy: 0,
10871088 notification: 4,
10881089 chat: 5,
10891090 now: 6,
1090− next: 7,
1091+ triage: 7,
1092+ next: 8,
10911093 };
10921094
10931095 export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
11521154 notification: "It is the most pressing unread notification.",
11531155 chat: "Nothing else is waiting, and this is the oldest message for you.",
11541156 now: "Nothing else is waiting on you, and this is yours in Now.",
1157+ triage: "Nothing else is waiting on you, and new work has waited over a week to be placed in the plan.",
11551158 next: "Nothing is waiting on you, so this is the work to take next.",
11561159 };
11571160
12001203 }
12011204
12021205 /**
1206+ * Work that has waited in Triage more than `TRIAGE_NUDGE_DAYS`, as a row of
1207+ * Your move for a viewer who plans (the Triage role or higher): after
1208+ * their own work in Now. None when nothing has waited that long.
1209+ */
1210+export function triageRow(slug: string, waiting: number): AttentionRow | null {
1211+ if (waiting <= 0) return null;
1212+ return {
1213+ key: `triage:${slug}`,
1214+ kind: "triage",
1215+ title: `${plural(waiting, "work item")} ${waiting === 1 ? "has" : "have"} waited in Triage over a week`,
1216+ detail: "Place each in the plan, or close it.",
1217+ stake: null,
1218+ action: { label: "Triage", to: workPath(slug, "triage") },
1219+ owner: null,
1220+ at: 0,
1221+ from: "plan",
1222+ };
1223+}
1224+
1225+/**
12031226 * Why the work item to take next (`nextWorkItem`) is the one: first in
12041227 * Now with nobody on it (or urgent there), else the top of Next; nothing
12051228 * open blocks it, nobody has claimed it, and it is assigned to nobody or
13891412 notifications: InboxItem[] | null;
13901413 chat: ChatEntryLike[] | null;
13911414 now?: AttentionRow[] | null;
1415+ /** How many have waited in Triage over a week, for a viewer who plans; undefined for one who does not. */
1416+ triageWaiting?: number | null;
13921417 next?: { item: WorkItem; viewer: string } | null;
13931418 }): Attention {
13941419 const rows: AttentionRow[] = [];
14111436 } else missing.push("Chat");
14121437 if (input.now) rows.push(...input.now);
14131438 else if (input.now === null) missing.push("Work");
1439+ const triage = input.triageWaiting ? triageRow(input.slug, input.triageWaiting) : null;
1440+ if (triage) rows.push(triage);
14141441 const ranked = rankAttention(rows);
14151442 const next = input.next ? { row: nextRow(input.next.item), why: nextWhy(input.next.item, input.next.viewer) } : null;
1416− return { rows: ranked, start: startHere(ranked) ?? next, missing };
1443+ // Work to take comes before a nudge to place new work.
1444+ const first = startHere(ranked);
1445+ const start = first?.row.kind === "triage" && next ? next : (first ?? next);
1446+ return { rows: ranked, start, missing };
14171447 }
14181448
14191449 // --- Spend in the span ----------------------------------------------------------
+53−0
55
66 import {
77 type PlanView,
8+ acceptForm,
89 allItemsPath,
910 applyMove,
1011 blockersToRead,
1112 budgetText,
13+ canAccept,
1214 capState,
15+ daysSince,
16+ duplicateComment,
1317 dropPosition,
18+ hillStep,
1419 listRedirect,
1520 menuHorizons,
1621 moreText,
2227 parseBudget,
2328 planInput,
2429 planPath,
30+ progressOf,
31+ quietLine,
32+ quietText,
33+ setAsideLine,
2534 setAsidePath,
35+ suggestionText,
36+ triageLine,
2637 triagePath,
2738 waitingLabel,
2839 waitingOn,
241252 assert.equal(moreText(10, 10), null);
242253 assert.equal(moreText(10, 1234), "1,224 more");
243254 });
255+
256+test("a hill's stage, in its four steps", () => {
257+ assert.deepEqual((["figuring_out", "decided", "building", "landing"] as const).map(hillStep), [0, 1, 2, 3]);
258+});
259+
260+test("a quiet row says for how long, from the last time anything happened on it", () => {
261+ const now = Date.parse("2026-10-19T12:00:00Z");
262+ assert.equal(daysSince("2026-10-13T11:00:00Z", now), 6);
263+ assert.equal(daysSince(null, now), null);
264+ assert.equal(daysSince("2026-10-20T00:00:00Z", now), 0);
265+ assert.equal(quietText({ quiet: true, activeAt: "2026-10-13T11:00:00Z" }, now), "Quiet for 6 days");
266+ assert.equal(quietText({ quiet: true, activeAt: "2026-10-18T11:00:00Z" }, now), "Quiet for 1 day");
267+ assert.equal(quietText({ quiet: true, activeAt: null }, now), "Quiet");
268+ assert.equal(quietText({ quiet: false, activeAt: "2026-10-01T00:00:00Z" }, now), null);
269+});
270+
271+test("the week: what went quiet, what was let go, and Triage waiting", () => {
272+ assert.equal(quietLine(0), null);
273+ assert.equal(quietLine(1), "1 in Now has gone quiet, with nothing happening on it for 5 days or more.");
274+ assert.equal(quietLine(3), "3 in Now have gone quiet, with nothing happening on them for 5 days or more.");
275+ assert.equal(setAsideLine(0), null);
276+ assert.equal(setAsideLine(4), "4 set aside in the last 30 days, untouched for 90 days in Later");
277+ assert.equal(triageLine(1, 0), "1 new work item waits in Triage to be placed.");
278+ assert.equal(triageLine(5, 2), "5 new work items wait in Triage to be placed. 2 of them have waited more than a week.");
279+ assert.equal(triageLine(3, 3), "3 new work items wait in Triage to be placed. All have waited more than a week.");
280+ assert.equal(triageLine(1, 1), "1 new work item waits in Triage to be placed. It has waited more than a week.");
281+ assert.deepEqual(progressOf([{ number: 4, hill: "building" }]), { 4: { number: 4, hill: "building" } });
282+});
283+
284+test("accepting a suggestion moves it where it says, at the bottom, with its appetite", () => {
285+ const next = { horizon: "next" as const, appetite: "medium" as const };
286+ assert.equal(suggestionText(next), "Next · M");
287+ assert.equal(suggestionText({ horizon: "later", appetite: null }), "Later");
288+ assert.deepEqual(acceptForm(next), { horizon: "next", appetite: "medium" });
289+ assert.deepEqual(acceptForm({ horizon: "later", appetite: null }), { horizon: "later" });
290+ // It is a plan move as the form posts it: no position, so the horizon puts it at the bottom.
291+ assert.deepEqual(planInput(new URLSearchParams(acceptForm(next))), { horizon: "next", appetite: "medium" });
292+ assert.equal(canAccept({ horizon: "now" }, false), false);
293+ assert.equal(canAccept({ horizon: "now" }, true), true);
294+ assert.equal(canAccept({ horizon: "next" }, false), true);
295+ assert.equal(duplicateComment(12), "Duplicate of #12");
296+});
+97−1
55 * move looks like before the work service answers, and how full Now is.
66 * Pure, so it is tested under Node.
77 */
8−import type { Appetite, Claim, Horizon, Plan, PlanGroup, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts";
8+import type { Appetite, Claim, Hill, Horizon, Plan, PlanGroup, PlanSuggestion, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts";
99
1010 import { workPath } from "./work-items.ts";
1111
1616 export const HORIZONS: readonly Horizon[] = ["triage", "now", "next", "later", "set_aside"];
1717 /** Every appetite, smallest first. */
1818 export const APPETITES: readonly Appetite[] = ["small", "medium", "large"];
19+/** Every stage of a hill, in the order work goes over it (the contracts' `HILLS`). */
20+export const HILL_STEPS: readonly Hill[] = ["figuring_out", "decided", "building", "landing"];
1921
2022 /** The Plan, Work's first page. */
2123 export function planPath(workspace: string, query = ""): string {
312314 export function moreText(shown: number, total: number): string | null {
313315 return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null;
314316 }
317+
318+// --- How Now stands -----------------------------------------------------------
319+
320+const DAY_MS = 86_400_000;
321+
322+/** Where `hill` is among its four stages: 0 (figuring it out) to 3 (landing). */
323+export function hillStep(hill: Hill): number {
324+ return Math.max(0, HILL_STEPS.indexOf(hill));
325+}
326+
327+/** Whole days since `at` (RFC 3339), never fewer than 0; null when there is no time. */
328+export function daysSince(at: string | null | undefined, now: number): number | null {
329+ const time = at ? Date.parse(at) : Number.NaN;
330+ if (Number.isNaN(time)) return null;
331+ return Math.max(0, Math.floor((now - time) / DAY_MS));
332+}
333+
334+/**
335+ * What a quiet row says: "Quiet for 6 days", counted from the last time
336+ * anything happened on it; "Quiet" alone when that is not known (or is
337+ * today, as clocks differ). Null
338+ * for one that is not quiet.
339+ */
340+export function quietText(progress: { quiet: boolean; activeAt: string | null }, now: number): string | null {
341+ if (!progress.quiet) return null;
342+ const days = daysSince(progress.activeAt, now);
343+ if (days == null || days < 1) return "Quiet";
344+ return `Quiet for ${days === 1 ? "1 day" : `${days.toLocaleString("en-US")} days`}`;
345+}
346+
347+/** How each work item in Now stands, by number. */
348+export function progressOf<P extends { number: number }>(progress: readonly P[]): Record<number, P> {
349+ return Object.fromEntries(progress.map((one) => [one.number, one]));
350+}
351+
352+/** Days with nothing happening before a work item in Now has gone quiet (the contracts' `QUIET_DAYS`). */
353+export const QUIET_AFTER_DAYS = 5;
354+
355+/** The week's line about Now going quiet; null for none. */
356+export function quietLine(count: number): string | null {
357+ if (count <= 0) return null;
358+ const one = count === 1;
359+ return `${count.toLocaleString("en-US")} in Now ${one ? "has" : "have"} gone quiet, with nothing happening on ${one ? "it" : "them"} for ${QUIET_AFTER_DAYS} days or more.`;
360+}
361+
362+/** The week's line about letting go; null for none set aside lately. */
363+export function setAsideLine(count: number): string | null {
364+ if (count <= 0) return null;
365+ return `${count.toLocaleString("en-US")} set aside in the last 30 days, untouched for 90 days in Later`;
366+}
367+
368+/** Triage's banner on the Plan: how many wait, and how many of them have waited more than a week. */
369+export function triageLine(total: number, waiting: number): string {
370+ const wait = total === 1 ? "1 new work item waits" : `${total.toLocaleString("en-US")} new work items wait`;
371+ const long =
372+ waiting <= 0
373+ ? ""
374+ : waiting >= total
375+ ? total === 1
376+ ? " It has waited more than a week."
377+ : " All have waited more than a week."
378+ : ` ${waiting.toLocaleString("en-US")} of them have waited more than a week.`;
379+ return `${wait} in Triage to be placed.${long}`;
380+}
381+
382+// --- Triage's suggestions -------------------------------------------------------
383+
384+/** A suggestion in a few words: "Next · M". */
385+export function suggestionText(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): string {
386+ const horizon = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" }[suggestion.horizon];
387+ const size = suggestion.appetite ? ` · ${suggestion.appetite === "small" ? "S" : suggestion.appetite === "medium" ? "M" : "L"}` : "";
388+ return `${horizon}${size}`;
389+}
390+
391+/**
392+ * Accepting a suggestion, as the form a fetcher posts (`planInput` reads
393+ * it): its horizon, where the horizon puts new work (the bottom of Next),
394+ * and its appetite when it has one.
395+ */
396+export function acceptForm(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): Record<string, string> {
397+ const form: Record<string, string> = { horizon: suggestion.horizon };
398+ if (suggestion.appetite) form.appetite = suggestion.appetite;
399+ return form;
400+}
401+
402+/** Whether the viewer may accept a suggestion: moving into Now takes more than placing work elsewhere. */
403+export function canAccept(suggestion: Pick<PlanSuggestion, "horizon">, canNow: boolean): boolean {
404+ return suggestion.horizon !== "triage" && (suggestion.horizon !== "now" || canNow);
405+}
406+
407+/** The comment a work item closed as a duplicate gets. */
408+export function duplicateComment(of: number): string {
409+ return `Duplicate of #${of}`;
410+}
+23−5
7777 import { page } from "../../lib/meta";
7878 import { inbox, work } from "../../lib/services.server";
7979 import { requireUser, roleIn } from "../../lib/session.server";
80+import { canItem } from "../../lib/work-access";
8081 import { homePagePath } from "../../lib/workspace-nav";
8182 import { Boundary } from "../../components/boundary";
8283
139140 const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null);
140141
141142 // Your own work in the plan's Now, and the work item to take next for when nothing else waits on you.
142− const nowP = Promise.all([work.listWorkItems(slug, viewer, { horizon: "now" }), work.listClaims(slug, viewer, { state: "active" })])
143− .then(([now, claims]) => (now.ok && claims.ok ? nowRows(now.value, claims.value, viewer.username) : null))
143+ // Whoever plans (the Triage role or higher) reads the plan instead of
144+ // the list: its Now is the same, and it says how many have waited in
145+ // Triage over a week, which they are asked to place.
146+ const plans = canItem(viewer, slug, null, "triage");
147+ const planP = plans
148+ ? work
149+ .getPlan(slug, viewer)
150+ .then((plan) => (plan.ok ? plan.value : null))
151+ .catch(() => null)
152+ : Promise.resolve(null);
153+ const nowItemsP = plans
154+ ? planP.then((plan) => plan?.now ?? null)
155+ : work
156+ .listWorkItems(slug, viewer, { horizon: "now" })
157+ .then((now) => (now.ok ? now.value : null))
158+ .catch(() => null);
159+ const nowP = Promise.all([nowItemsP, work.listClaims(slug, viewer, { state: "active" })])
160+ .then(([now, claims]) => (now && claims.ok ? nowRows(now, claims.value, viewer.username) : null))
144161 .catch(() => null);
162+ const triageWaitingP = planP.then((plan) => (plans ? (plan?.triageWaiting ?? null) : undefined));
145163 const nextP = work
146164 .nextWorkItem(slug, viewer)
147165 .then((result) => (result.ok && result.value ? { item: result.value, viewer: viewer.username } : null))
148166 .catch(() => null);
149− const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP, nowP, nextP])
150− .then(([mission, notifications, sidebar, now, next]) =>
151− attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null, now, next }),
167+ const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP, nowP, nextP, triageWaitingP])
168+ .then(([mission, notifications, sidebar, now, next, triageWaiting]) =>
169+ attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null, now, triageWaiting, next }),
152170 )
153171 .catch(() => ({ rows: [], start: null, missing: ["Code", "Notifications", "Chat", "Work"] }));
154172
+10−2
115115 const origin = originOf(item.repos);
116116 // Who may do what follows the item: the better of their role in the
117117 // workspace and on its origin repository (lib/work-access.ts).
118− const [originRepo, links, subscription, sessionTrees] = await Promise.all([
118+ const [originRepo, links, subscription, sessionTrees, progress] = await Promise.all([
119119 origin ? repos.getById(origin.repoId, viewer).catch(() => null) : null,
120120 // What it is tied to outside g1t. Shown only once the item is known visible.
121121 part(integrations.links(workspace, number), "Links from outside g1t"),
128128 ),
129129 "Sessions",
130130 ),
131+ // How it stands while it is in Now, as the plan reads it, for the sidebar's Plan: it does without when it cannot be read.
132+ item.horizon === "now" && item.state === "open"
133+ ? work
134+ .getPlan(workspace, viewer)
135+ .then((plan) => (plan.ok ? (plan.value.progress.find((one) => one.number === number) ?? null) : null))
136+ .catch(() => null)
137+ : null,
131138 ]);
132139 const ref = originRepo?.ok ? { id: originRepo.value.id, namespace: originRepo.value.namespace, isPrivate: originRepo.value.isPrivate } : null;
133140 const can = itemAbilities(viewer, workspace, ref);
151158 subscription,
152159 coordination: isFailed(coordination) ? coordination : coordination.ok ? coordination.value : null,
153160 sessionTrees,
161+ progress,
154162 // Only when it can start: the button is not shown otherwise.
155163 agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null,
156164 endedClaims: ended
10171025 ) : (
10181026 <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} />
10191027 )}
1020− <PlanBox item={item} workspace={workspace} canEdit={can.triage} canNow={can.manage_labels} />
1028+ <PlanBox item={item} workspace={workspace} canEdit={can.triage} canNow={can.manage_labels} progress={loaderData.progress} />
10211029 {viewer &&
10221030 (isFailed(loaderData.subscription) ? (
10231031 <SideUnavailable heading="Notifications" what="Your notification settings" />
+64−6
1−import { ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react";
1+import { CheckCircle2, ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react";
22 import { type DragEvent, useEffect, useMemo, useState } from "react";
33 import { Link, redirect, useFetcher } from "react-router";
44
66
77 import type { Route } from "./+types/plan";
88 import { CapBar, PlanRow, PlanRowMenu, type RowDrag } from "../../../components/plan";
9−import { ButtonLink } from "../../../components/ui";
9+import { ButtonLink, TimeAgo } from "../../../components/ui";
1010 import { Alert } from "../../../components/ui/alert";
1111 import { Button } from "../../../components/ui/button";
1212 import { Card } from "../../../components/ui/card";
2929 onItems,
3030 planInput,
3131 planPath,
32+ progressOf,
33+ quietLine,
34+ setAsideLine,
3235 setAsidePath,
36+ triageLine,
3337 triagePath,
3438 waitingOn,
3539 } from "../../../lib/plan";
3943 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
4044 import { useWorkspaceLive } from "../../../lib/use-repo-live";
4145 import { canItem } from "../../../lib/work-access";
42−import { newItemPath, shortPath, workPath } from "../../../lib/work-items";
46+import { itemPath, newItemPath, shortPath, workPath } from "../../../lib/work-items";
4347
4448 export function meta({ params, ...args }: Route.MetaArgs) {
4549 return page(args, { title: `Plan · Work · ${params.owner} · g1t` });
220224 });
221225
222226 const cap = capState(plan.now.length, loaderData.plan.nowCap);
227+ // How each in Now stands, and the week: read with the plan, and left as they are while a move is on its way.
228+ const { landed, triageWaiting, setAsideRecent } = loaderData.plan;
229+ const progress = useMemo(() => progressOf(loaderData.plan.progress), [loaderData.plan.progress]);
230+ const quiet = quietLine(loaderData.plan.progress.filter((one) => one.quiet).length);
231+ const letGo = setAsideLine(setAsideRecent);
223232 const nextMore = moreText(plan.next.length, plan.nextTotal);
224233 const row = (lane: "now" | "next", item: WorkItem, index: number, rank: number) => (
225234 <PlanRow
231240 waiting={waitingOn(item, closed)}
232241 drag={rowDrag(lane, item, index)}
233242 menu={canPlan ? <PlanRowMenu item={item} list={lists[lane]} canNow={canNow} onMove={(move) => submit(move, item)} /> : undefined}
243+ progress={lane === "now" ? progress[item.number] : undefined}
234244 />
235245 );
236246
272282 className="mt-4 flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-sm text-muted transition-colors hover:border-line-strong hover:text-fg"
273283 >
274284 <Inbox size={15} className="shrink-0 text-accent" />
275− <span className="min-w-0 grow">
276− {plan.triageTotal === 1 ? "1 new work item waits" : `${plan.triageTotal.toLocaleString("en-US")} new work items wait`} in Triage to be placed.
277− </span>
285+ <span className="min-w-0 grow">{triageLine(plan.triageTotal, Math.min(triageWaiting, plan.triageTotal))}</span>
278286 <ChevronRight size={15} className="shrink-0" />
279287 </Link>
280288 )}
341349 </Card>
342350
343351 <div className="grid min-w-0 content-start gap-4">
352+ <Card asChild className="min-w-0">
353+ <section aria-labelledby="plan-week">
354+ <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2">
355+ <h2 id="plan-week" className="text-sm font-semibold">
356+ This week
357+ </h2>
358+ <span className="text-xs text-faint">what landed from Now</span>
359+ </header>
360+ {landed.length === 0 ? (
361+ <p className="px-4 pb-2 text-xs text-faint">Nothing has landed from Now in the last 7 days.</p>
362+ ) : (
363+ <ul className="px-2 pb-1">
364+ {landed.map((item) => (
365+ <li key={item.id}>
366+ <Link
367+ to={itemPath(workspace, item.number)}
368+ prefetch="intent"
369+ className="flex min-w-0 items-baseline gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-raised/60 hover:text-accent"
370+ >
371+ <CheckCircle2 size={13} className="shrink-0 translate-y-0.5 text-success" aria-hidden />
372+ <span className="min-w-0 grow truncate">
373+ <span className="mr-1.5 font-mono text-xs text-faint">#{item.number}</span>
374+ {item.title}
375+ </span>
376+ {item.closedAt && (
377+ <span className="shrink-0 text-xs text-faint">
378+ <TimeAgo at={item.closedAt} />
379+ </span>
380+ )}
381+ </Link>
382+ </li>
383+ ))}
384+ </ul>
385+ )}
386+ {(quiet || letGo) && (
387+ <div className="space-y-1 border-t border-line px-4 py-2.5 text-xs text-muted">
388+ {quiet && <p>{quiet}</p>}
389+ {letGo && (
390+ <p>
391+ <Link to={setAsidePath(workspace)} className="hover:text-fg">
392+ {letGo}
393+ </Link>
394+ .
395+ </p>
396+ )}
397+ </div>
398+ )}
399+ {!quiet && !letGo && <div className="pb-2" />}
400+ </section>
401+ </Card>
344402 <Card asChild className={cn("min-w-0", over?.lane === "later" && "border-accent/60 bg-accent/5")}>
345403 <section aria-labelledby="plan-later" {...laneDrop("later")}>
346404 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2">
+62−4
1−import { Inbox, Plus, X } from "lucide-react";
1+import { Check, Copy, Inbox, Plus, Sparkles, X } from "lucide-react";
22 import { Link, useFetcher } from "react-router";
33
44 import { APPETITES, LIST_PAGE, type WorkItem, appetiteLetter } from "@g1t/contracts";
1212 import { RepoChips } from "../../../components/work-items";
1313 import { page } from "../../../lib/meta";
1414 import { isFailed, part } from "../../../lib/partial";
15−import { APPETITE_HINTS, APPETITE_SUMMARY, moreText, planInput, planPath } from "../../../lib/plan";
15+import { APPETITE_HINTS, APPETITE_SUMMARY, acceptForm, canAccept, duplicateComment, moreText, planInput, planPath, suggestionText } from "../../../lib/plan";
1616 import { workPages } from "../../../lib/repo-live";
1717 import { work } from "../../../lib/services.server";
1818 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
5353 const form = await request.formData();
5454 const number = Number(form.get("number"));
5555 if (!Number.isInteger(number) || number < 1) return { error: "Choose a work item.", number: null };
56+ // A duplicate: said in a comment, then closed as not planned, as its own page would.
57+ if (form.get("action") === "close-duplicate") {
58+ const of = Number(form.get("of"));
59+ if (!Number.isInteger(of) || of < 1 || of === number) return { error: "Choose the work item it repeats.", number };
60+ const said = await work.addComment(user, { workspace, number }, { body: duplicateComment(of) });
61+ if (!said.ok) return { error: said.error.message, number };
62+ const result = await work.closeWorkItem(user, workspace, number, "not_planned");
63+ return result.ok ? { error: null, number } : { error: result.error.message, number };
64+ }
5665 // Closing as not planned, as the item's own page does.
5766 if (form.get("action") === "close-not-planned") {
5867 const result = await work.closeWorkItem(user, workspace, number, "not_planned");
6675
6776 /**
6877 * Work's Triage: the work items nobody has placed in the plan yet, newest
69− * first, each with the moves that place it.
78+ * first, each with the moves that place it, and where g1t suggests it goes
79+ * (drafted with the workspace's own AI provider), which one click accepts.
7080 */
7181 export default function Triage({ loaderData }: Route.ComponentProps) {
7282 const { workspace, items, triageTotal, canPlan, canNow, member } = loaderData;
7383 useWorkspaceLive({ workspace, member, match: workPages.list() });
7484 const more = moreText(items.length, triageTotal);
85+ // Only a workspace with an AI provider gets suggestions, so one shown says it has one.
86+ const suggesting = items.some((item) => item.suggestion);
7587 return (
7688 <div>
7789 <div className="flex flex-wrap items-center justify-between gap-3">
8395 <p className="mt-1 text-sm text-muted">
8496 New work items land here until someone places them: in Now, in Next to be taken in turn, or in Later. Give each an appetite while you are at it.
8597 </p>
98+ {suggesting && (
99+ <p className="mt-1 flex items-center gap-1.5 text-xs text-faint">
100+ <Sparkles size={12} className="shrink-0" aria-hidden />
101+ g1t suggests where new work goes using your workspace's AI provider; nothing moves until someone accepts.
102+ </p>
103+ )}
86104 </div>
87105 {loaderData.canOpen && (
88106 <ButtonLink to={newItemPath(workspace)} className="max-sm:h-10">
121139 const busy = fetcher.state !== "idle";
122140 const posted = fetcher.formData;
123141 // Placed or closed: gone from Triage before the answer, back if it was refused.
124− const leaving = busy && posted != null && (posted.has("horizon") || posted.get("action") === "close-not-planned");
142+ const leaving =
143+ busy && posted != null && (posted.has("horizon") || posted.get("action") === "close-not-planned" || posted.get("action") === "close-duplicate");
144+ const suggestion = item.suggestion ?? null;
125145 const appetite = busy && posted?.has("appetite") ? (String(posted.get("appetite")) as WorkItem["appetite"] | "none") : item.appetite;
126146 const post = (fields: Record<string, string>) => fetcher.submit({ number: String(item.number), ...fields }, { method: "post" });
127147 if (leaving) return null;
139159 <RepoChips repos={item.repos} workspace={workspace} />
140160 </div>
141161 </div>
162+ {suggestion && (
163+ <Card tone="plain" radius="lg" className="flex min-w-0 flex-col gap-2 border-accent/20 bg-accent/5 px-3 py-2 sm:flex-row sm:items-center">
164+ <div className="min-w-0 grow text-xs">
165+ <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
166+ <Sparkles size={12} className="shrink-0 text-accent" aria-hidden />
167+ <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span>
168+ {suggestion.duplicateOf != null && (
169+ <>
170+ <span className="text-faint">·</span>
171+ <span className="text-muted">
172+ Looks like{" "}
173+ <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline">
174+ #{suggestion.duplicateOf}
175+ </Link>
176+ </span>
177+ </>
178+ )}
179+ </p>
180+ <p className="mt-0.5 text-muted">{suggestion.why}</p>
181+ </div>
182+ {canPlan && (
183+ <div className="flex shrink-0 flex-wrap items-center gap-2">
184+ {canAccept(suggestion, canNow) && (
185+ <Button size="sm" variant="accent" disabled={busy} onClick={() => post(acceptForm(suggestion))}>
186+ <Check size={14} />
187+ Accept
188+ </Button>
189+ )}
190+ {suggestion.duplicateOf != null && (
191+ <Button size="sm" variant="outline" disabled={busy} onClick={() => post({ action: "close-duplicate", of: String(suggestion.duplicateOf) })}>
192+ <Copy size={14} />
193+ Close as duplicate
194+ </Button>
195+ )}
196+ </div>
197+ )}
198+ </Card>
199+ )}
142200 {canPlan && (
143201 <div className="flex flex-wrap items-center gap-2">
144202 {canNow && (
+3−0
178178 /// placed.
179179 #[serde(default)]
180180 pub planned_at: Option<String>,
181+ /// While it is in Triage: where g1t suggests it goes.
182+ #[serde(default)]
183+ pub suggestion: Option<super::PlanSuggestion>,
181184 }
182185
183186 impl WorkItem {
+135−0
120120 pub const NEXT_SHOWN: u32 = 10;
121121 /// The most spend budget a work item may be given, in dollars.
122122 pub const MAX_BUDGET_USD: f64 = 100_000.0;
123+/// A work item in Triage this many days is waiting too long: Home says so
124+/// to those who plan.
125+pub const TRIAGE_NUDGE_DAYS: u32 = 7;
126+/// A work item in Later untouched this many days is set aside.
127+pub const SET_ASIDE_DAYS: u32 = 90;
128+/// A work item in Now with nothing happening on it this many days has gone
129+/// quiet.
130+pub const QUIET_DAYS: u32 = 5;
131+/// How far back the plan's week looks, in days.
132+pub const WEEK_DAYS: u32 = 7;
133+
134+/// What g1t suggests for a work item in Triage: drafted by the
135+/// workspace's own AI provider from the work item and the plan, for a
136+/// person to accept or not.
137+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138+#[serde(rename_all = "camelCase")]
139+pub struct PlanSuggestion {
140+ pub horizon: Horizon,
141+ #[serde(default)]
142+ pub appetite: Option<Appetite>,
143+ /// An open work item it seems to repeat, by number.
144+ #[serde(default)]
145+ pub duplicate_of: Option<u32>,
146+ /// Why, in a sentence.
147+ pub why: String,
148+ /// RFC 3339.
149+ pub at: String,
150+}
151+
152+/// How far along a work item in Now is, as its record says: what its
153+/// sessions, decisions and pull requests show, never set by hand.
154+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
155+#[serde(rename_all = "snake_case")]
156+pub enum Hill {
157+ /// Nothing decided yet: sessions on it, or nothing at all.
158+ FiguringOut,
159+ /// A decision is recorded on it: the approach is settled.
160+ Decided,
161+ /// A draft pull request, or commits made for it.
162+ Building,
163+ /// A pull request in review or merged, or landed in some of its
164+ /// targets.
165+ Landing,
166+}
167+
168+impl Hill {
169+ pub fn as_str(self) -> &'static str {
170+ match self {
171+ Hill::FiguringOut => "figuring_out",
172+ Hill::Decided => "decided",
173+ Hill::Building => "building",
174+ Hill::Landing => "landing",
175+ }
176+ }
123177
178+ pub fn label(self) -> &'static str {
179+ match self {
180+ Hill::FiguringOut => "Figuring it out",
181+ Hill::Decided => "Decided",
182+ Hill::Building => "Building",
183+ Hill::Landing => "Landing",
184+ }
185+ }
186+}
187+
188+/// What a work item's record says of it, for its hill.
189+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
190+pub struct HillFacts {
191+ pub decisions: u32,
192+ pub draft_pulls: u32,
193+ pub commits: u32,
194+ pub open_pulls: u32,
195+ pub merged_pulls: u32,
196+ pub landed_targets: u32,
197+}
198+
199+/// Where a work item with `facts` is on its hill: the furthest stage any
200+/// fact reaches.
201+pub fn hill_of(facts: &HillFacts) -> Hill {
202+ if facts.open_pulls > 0 || facts.merged_pulls > 0 || facts.landed_targets > 0 {
203+ Hill::Landing
204+ } else if facts.draft_pulls > 0 || facts.commits > 0 {
205+ Hill::Building
206+ } else if facts.decisions > 0 {
207+ Hill::Decided
208+ } else {
209+ Hill::FiguringOut
210+ }
211+}
212+
213+/// How a work item in Now stands.
214+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215+#[serde(rename_all = "camelCase")]
216+pub struct Progress {
217+ pub number: u32,
218+ pub hill: Hill,
219+ /// RFC 3339: the last time anything happened on it (a session, a
220+ /// decision, a pull request, a change to it).
221+ #[serde(default)]
222+ pub active_at: Option<String>,
223+ /// Nothing has happened on it for [`QUIET_DAYS`].
224+ #[serde(default)]
225+ pub quiet: bool,
226+ /// Where it stands, in a sentence or two, drafted by g1t from its
227+ /// record with the workspace's AI provider; none until it has one.
228+ #[serde(default)]
229+ pub standing: Option<String>,
230+ /// RFC 3339: when `standing` was drafted.
231+ #[serde(default)]
232+ pub standing_at: Option<String>,
233+}
234+
124235 /// What `Some(None)` means in a change: the field is cleared. Absent
125236 /// leaves it as it is; JSON `null` clears it.
126237 fn present<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
202313 /// the workspace), the largest group first.
203314 pub later: Vec<PlanGroup>,
204315 pub set_aside_total: u32,
316+ /// How each work item in Now stands, in Now's order.
317+ #[serde(default)]
318+ pub progress: Vec<Progress>,
319+ /// What closed from Now in the last [`WEEK_DAYS`], the most recent
320+ /// first; at most 10.
321+ #[serde(default)]
322+ pub landed: Vec<WorkItem>,
323+ /// How many have waited in Triage more than [`TRIAGE_NUDGE_DAYS`].
324+ #[serde(default)]
325+ pub triage_waiting: u32,
326+ /// How many were set aside in the last 30 days.
327+ #[serde(default)]
328+ pub set_aside_recent: u32,
205329 }
206330
207331 /// Work items of one horizon filed in one repository.
323447 }
324448
325449 #[test]
450+ fn the_hill_is_the_furthest_stage_the_record_reaches() {
451+ assert_eq!(hill_of(&HillFacts::default()), Hill::FiguringOut);
452+ assert_eq!(hill_of(&HillFacts { decisions: 2, ..HillFacts::default() }), Hill::Decided);
453+ assert_eq!(hill_of(&HillFacts { decisions: 1, commits: 3, ..HillFacts::default() }), Hill::Building);
454+ assert_eq!(hill_of(&HillFacts { draft_pulls: 1, ..HillFacts::default() }), Hill::Building);
455+ assert_eq!(hill_of(&HillFacts { open_pulls: 1, ..HillFacts::default() }), Hill::Landing);
456+ assert_eq!(hill_of(&HillFacts { landed_targets: 1, draft_pulls: 1, ..HillFacts::default() }), Hill::Landing);
457+ assert_eq!(serde_json::to_value(Hill::FiguringOut).unwrap(), "figuring_out");
458+ }
459+
460+ #[test]
326461 fn horizons_and_appetites_read_as_people_write_them() {
327462 assert_eq!(Horizon::parse("Set aside"), Some(Horizon::SetAside));
328463 assert_eq!(Horizon::parse("set-aside"), Some(Horizon::SetAside));
+1−1
11 [package]
22 name = "g1t"
3−version = "0.4.2"
3+version = "0.4.3"
44 edition.workspace = true
55 license.workspace = true
66 repository.workspace = true
+40−2
815815 }
816816 }
817817
818+/// A hill's stage, as people say it.
819+fn hill_label(name: &str) -> &'static str {
820+ match name {
821+ "decided" => "Decided",
822+ "building" => "Building",
823+ "landing" => "Landing",
824+ _ => "Figuring it out",
825+ }
826+}
827+
818828 /// An appetite as given: `S`, `small` and so on, or `none` to clear it.
819829 pub fn appetite_field(text: &str) -> Result<Value> {
820830 match text.trim().to_ascii_lowercase().as_str() {
935945 if now.is_empty() {
936946 println!("{}", out.dim(" Nothing in Now. Move what you're taking on: g1t work plan <number> --now"));
937947 } else {
938− let rows: Vec<Vec<Cell>> = now.iter().enumerate().map(|(i, item)| plan_row(out, i + 1, item)).collect();
939− out.table(&["", "number", "title", "appetite", "", "waits"], &rows)?;
948+ let progress = list_of(plan, "progress");
949+ let of = |item: &Value| progress.iter().find(|p| p["number"] == item["number"]);
950+ let rows: Vec<Vec<Cell>> = now
951+ .iter()
952+ .enumerate()
953+ .map(|(i, item)| {
954+ let mut row = plan_row(out, i + 1, item);
955+ let stage = of(item).map(|p| hill_label(s(p, "hill"))).unwrap_or_default();
956+ let quiet = if of(item).and_then(|p| p["quiet"].as_bool()) == Some(true) { " · quiet" } else { "" };
957+ let stage = format!("{stage}{quiet}");
958+ row.insert(3, Cell::styled(stage.clone(), out.dim(&stage)));
959+ row
960+ })
961+ .collect();
962+ out.table(&["", "number", "title", "hill", "appetite", "", "waits"], &rows)?;
963+ // Where each stands, as g1t drafted it from its record.
964+ for item in now {
965+ if let Some(standing) = of(item).and_then(|p| p["standing"].as_str()) {
966+ println!("{}", out.dim(&format!(" #{}: {standing}", item["number"])));
967+ }
968+ }
969+ }
970+ let landed = list_of(plan, "landed");
971+ if !landed.is_empty() {
972+ let list: Vec<String> = landed.iter().map(|item| format!("#{}", item["number"])).collect();
973+ println!("{}", out.dim(&format!(" Landed from Now this week: {}", list.join(", "))));
940974 }
941975 println!();
942976 println!("{}", out.bold(&format!("Next · {}", count("next_total"))));
960994 count("set_aside_total")
961995 ))
962996 );
997+ let waiting = count("triage_waiting");
998+ if waiting > 0 {
999+ println!("{}", out.dim(&format!("{waiting} waited in Triage over a week: g1t work list --horizon triage")));
1000+ }
9631001 Ok(())
9641002 }
9651003
+52−0
113113 urgent: boolean;
114114 /** RFC 3339: when it was last moved in the plan; null for one never placed. */
115115 plannedAt: string | null;
116+ /** While it is in Triage: where g1t suggests it goes. */
117+ suggestion?: PlanSuggestion | null;
118+};
119+
120+/** What g1t suggests for a work item in Triage, drafted by the workspace's own AI provider, for a person to accept or not. */
121+export type PlanSuggestion = {
122+ horizon: Horizon;
123+ appetite: Appetite | null;
124+ /** An open work item it seems to repeat. */
125+ duplicateOf: number | null;
126+ why: string;
127+ /** RFC 3339. */
128+ at: string;
129+};
130+
131+/** How far along a work item in Now is, as its record says; never set by hand. */
132+export type Hill = "figuring_out" | "decided" | "building" | "landing";
133+
134+export const HILLS: Hill[] = ["figuring_out", "decided", "building", "landing"];
135+
136+export function hillLabel(hill: Hill): string {
137+ return { figuring_out: "Figuring it out", decided: "Decided", building: "Building", landing: "Landing" }[hill];
138+}
139+
140+/** How a work item in Now stands. */
141+export type Progress = {
142+ number: number;
143+ hill: Hill;
144+ /** RFC 3339: the last time anything happened on it. */
145+ activeAt: string | null;
146+ /** Nothing has happened on it for `QUIET_DAYS`. */
147+ quiet: boolean;
148+ /** Where it stands, drafted by g1t from its record; null until it has one. */
149+ standing: string | null;
150+ standingAt: string | null;
116151 };
117152
153+/** Days in Triage before Home says a work item is waiting too long. */
154+export const TRIAGE_NUDGE_DAYS = 7;
155+/** Days untouched in Later before a work item is set aside. */
156+export const SET_ASIDE_DAYS = 90;
157+/** Days with nothing happening before a work item in Now has gone quiet. */
158+export const QUIET_DAYS = 5;
159+/** How far back the plan's week looks. */
160+export const WEEK_DAYS = 7;
161+
118162 /**
119163 * Where an open work item stands in its workspace's plan. New ones start
120164 * in Triage; within Now, Next and Later the order is the priority. Mirrors
161205 /** Later, by the repository each is filed in (null: filed in the workspace), the largest group first. */
162206 later: PlanGroup[];
163207 setAsideTotal: number;
208+ /** How each work item in Now stands, in Now's order. */
209+ progress: Progress[];
210+ /** What closed from Now in the last `WEEK_DAYS`, the most recent first; at most 10. */
211+ landed: WorkItem[];
212+ /** How many have waited in Triage more than `TRIAGE_NUDGE_DAYS`. */
213+ triageWaiting: number;
214+ /** How many were set aside in the last 30 days. */
215+ setAsideRecent: number;
164216 };
165217
166218 export type PlanGroup = { repo: string | null; count: number };
+12−0
1+-- The plan reads itself (items/plan_settle.rs). For a work item in Triage,
2+-- where g1t suggests it goes (suggestion: JSON { horizon, appetite,
3+-- duplicate_of, why }), asked of the workspace's AI provider once
4+-- (suggested_at, set even when there was no answer). For one in Now, where it
5+-- stands in a sentence or two (standing), drafted when something new has
6+-- happened on it since standing_at.
7+ALTER TABLE work_items ADD COLUMN suggestion TEXT;
8+ALTER TABLE work_items ADD COLUMN suggested_at TEXT;
9+ALTER TABLE work_items ADD COLUMN standing TEXT;
10+ALTER TABLE work_items ADD COLUMN standing_at TEXT;
11+
12+CREATE INDEX work_items_unsuggested ON work_items (state, horizon, suggested_at) WHERE state = 'open';
+1−0
5555 mod milestones;
5656 mod numbering;
5757 mod plan;
58+mod plan_settle;
5859 mod reading;
5960 mod refs;
6061 mod rows;
+191−1
6969 (SELECT min(rank) FROM work_items WHERE workspace = ?1 AND state = 'open' AND COALESCE(horizon, 'triage') = ?2
7070 AND id <> ?3 AND rank IS NOT NULL AND (?5 IS NULL OR rank > ?5)) AS behind";
7171
72+/// What the record says of each work item in the JSON array of ids `?1`:
73+/// its hill's facts, when anything last happened on it, and where g1t
74+/// last said it stands.
75+pub(crate) const FACTS: &str = "SELECT w.number,
76+ (SELECT count(*) FROM decisions d WHERE d.item_id = w.id
77+ AND d.kind NOT IN ('claim', 'merge', 'integration_failure', 'integration_passed')) AS decisions,
78+ (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'draft') AS draft_pulls,
79+ (SELECT count(*) FROM session_touches t JOIN sessions s ON s.id = t.session_id
80+ WHERE s.item_id = w.id AND t.kind = 'commit') AS commits,
81+ (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'open') AS open_pulls,
82+ (SELECT count(*) FROM pulls p WHERE p.item_id = w.id AND p.status = 'merged') AS merged_pulls,
83+ (SELECT count(*) FROM work_item_repos r WHERE r.item_id = w.id AND r.landed_at IS NOT NULL) AS landed_targets,
84+ max(w.updated_at,
85+ COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.item_id = w.id), ''),
86+ COALESCE((SELECT max(d.created_at) FROM decisions d WHERE d.item_id = w.id), ''),
87+ COALESCE((SELECT max(p.updated_at) FROM pulls p WHERE p.item_id = w.id), '')) AS active_at,
88+ w.standing, w.standing_at
89+ FROM work_items w WHERE w.id IN (SELECT value FROM json_each(?1))";
90+
91+#[derive(Deserialize)]
92+pub(crate) struct FactsRow {
93+ pub number: u32,
94+ #[serde(default)]
95+ pub decisions: u32,
96+ #[serde(default)]
97+ pub draft_pulls: u32,
98+ #[serde(default)]
99+ pub commits: u32,
100+ #[serde(default)]
101+ pub open_pulls: u32,
102+ #[serde(default)]
103+ pub merged_pulls: u32,
104+ #[serde(default)]
105+ pub landed_targets: u32,
106+ #[serde(default)]
107+ pub active_at: Option<String>,
108+ #[serde(default)]
109+ pub standing: Option<String>,
110+ #[serde(default)]
111+ pub standing_at: Option<String>,
112+}
113+
114+impl FactsRow {
115+ pub(crate) fn hill(&self) -> Hill {
116+ hill_of(&HillFacts {
117+ decisions: self.decisions,
118+ draft_pulls: self.draft_pulls,
119+ commits: self.commits,
120+ open_pulls: self.open_pulls,
121+ merged_pulls: self.merged_pulls,
122+ landed_targets: self.landed_targets,
123+ })
124+ }
125+
126+ /// How it stands, quiet when nothing has happened since `quiet_before`
127+ /// (RFC 3339).
128+ pub(crate) fn progress(self, quiet_before: &str) -> Progress {
129+ let hill = self.hill();
130+ let active_at = self.active_at.filter(|at| !at.is_empty());
131+ Progress {
132+ number: self.number,
133+ hill,
134+ quiet: active_at.as_deref().is_some_and(|at| at < quiet_before),
135+ active_at,
136+ standing: self.standing,
137+ standing_at: self.standing_at,
138+ }
139+ }
140+}
141+
142+/// The RFC 3339 time `days` before `now_ms`.
143+pub(crate) fn days_before(now_ms: u64, days: u32) -> String {
144+ rfc3339(now_ms.saturating_sub(u64::from(days) * 86_400_000))
145+}
146+
147+/// What closed from Now since `?5`, the most recently closed first.
148+const LANDED: &str = "SELECT {ITEM_COLUMNS} FROM work_items
149+ WHERE work_items.workspace = ?1 AND work_items.state = 'closed' AND work_items.number IS NOT NULL
150+ AND work_items.horizon = 'now' AND work_items.closed_at >= ?5
151+ AND (?4 IS NULL OR EXISTS (SELECT 1 FROM work_item_repos r WHERE r.item_id = work_items.id AND r.repo_id = ?4))
152+ AND (?2 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
153+ WHERE o.item_id = work_items.id AND o.role = 'origin'
154+ AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?3)))))
155+ ORDER BY work_items.closed_at DESC LIMIT 10";
156+
157+/// How many have waited in Triage since before `?5`, and how many were set
158+/// aside since `?6`.
159+const WAITING: &str = "SELECT
160+ (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND COALESCE(work_items.horizon, 'triage') = 'triage'
161+ AND work_items.created_at < ?5) AS triage_waiting,
162+ (SELECT count(*) FROM work_items WHERE {OPEN_VISIBLE} AND work_items.horizon = 'set_aside'
163+ AND work_items.planned_at >= ?6) AS set_aside_recent";
164+
165+#[derive(Deserialize)]
166+struct Waiting {
167+ #[serde(default)]
168+ triage_waiting: u32,
169+ #[serde(default)]
170+ set_aside_recent: u32,
171+}
172+
72173 #[derive(Deserialize)]
73174 struct Neighbours {
74175 ahead: Option<String>,
356457 values
357458 };
358459 let shown = if a.all { crate::LIST_PAGE } else { NEXT_SHOWN };
460+ let now_ms = now_ms();
461+ let week = days_before(now_ms, WEEK_DAYS);
462+ let mut waiting_values = bound(Some(days_before(now_ms, TRIAGE_NUDGE_DAYS).into()));
463+ waiting_values.push(days_before(now_ms, 30).into());
359464 let statements = vec![
360465 self.db.prepare(open_items(&format!("{HORIZON} = 'now'"), crate::LIST_PAGE)).bind(&bound(None))?,
361466 self.db.prepare(open_items(&format!("{HORIZON} = 'next'"), shown)).bind(&bound(None))?,
362467 self.db.prepare(COUNTS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?,
363468 self.db.prepare(LATER_GROUPS.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&bound(None))?,
469+ self.db.prepare(LANDED.replace("{ITEM_COLUMNS}", ITEM_COLUMNS)).bind(&bound(Some(week.as_str().into())))?,
470+ self.db.prepare(WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE)).bind(&waiting_values)?,
364471 ];
365− let results = self.timing.db(4, self.db.batch(statements)).await?;
472+ let results = self.timing.db(6, self.db.batch(statements)).await?;
366473 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
367474 let now = shown_rows(rows(0)?.results::<ItemRow>()?, &a.viewer);
368475 let next = shown_rows(rows(1)?.results::<ItemRow>()?, &a.viewer);
371478 counts.iter().filter(|row| row.horizon.as_deref().and_then(Horizon::parse).unwrap_or_default() == horizon).map(|row| row.count).sum()
372479 };
373480 let later = rows(3)?.results::<Group>()?.into_iter().map(|group| PlanGroup { repo: group.repo, count: group.count }).collect();
481+ let landed = shown_rows(rows(4)?.results::<ItemRow>()?, &a.viewer);
482+ let waiting = rows(5)?.results::<Waiting>()?.into_iter().next().unwrap_or(Waiting { triage_waiting: 0, set_aside_recent: 0 });
483+ // How each in Now stands, in Now's order.
484+ let ids: Vec<&str> = now.iter().map(|item| item.id.as_str()).collect();
485+ let mut facts: Vec<FactsRow> = if ids.is_empty() {
486+ Vec::new()
487+ } else {
488+ self.db.prepare(FACTS).bind(&[serde_json::to_string(&ids)?.into()])?.all().await?.results::<FactsRow>()?
489+ };
490+ let quiet_before = days_before(now_ms, QUIET_DAYS);
491+ let progress = now
492+ .iter()
493+ .filter_map(|item| {
494+ let at = facts.iter().position(|row| row.number == item.number)?;
495+ Some(facts.swap_remove(at).progress(&quiet_before))
496+ })
497+ .collect();
374498 Ok(Outcome::Ok(Plan {
499+ progress,
500+ landed,
501+ triage_waiting: waiting.triage_waiting,
502+ set_aside_recent: waiting.set_aside_recent,
375503 now_cap: self.now_cap(&workspace).await?,
376504 now,
377505 next,
595723 assert_eq!(groups, [(Some("acme/web".into()), 2)]);
596724 }
597725
726+ /// The facts `FACTS` reads for work item `number`.
727+ fn facts(db: &Connection, number: u32) -> FactsRow {
728+ let ids = format!("[\"wki_{number}\"]");
729+ db.query_row(FACTS, [ids], |row| {
730+ Ok(FactsRow {
731+ number: row.get("number")?,
732+ decisions: row.get("decisions")?,
733+ draft_pulls: row.get("draft_pulls")?,
734+ commits: row.get("commits")?,
735+ open_pulls: row.get("open_pulls")?,
736+ merged_pulls: row.get("merged_pulls")?,
737+ landed_targets: row.get("landed_targets")?,
738+ active_at: row.get("active_at")?,
739+ standing: row.get("standing")?,
740+ standing_at: row.get("standing_at")?,
741+ })
742+ })
743+ .unwrap()
744+ }
745+
746+ #[test]
747+ fn the_record_says_how_far_along_each_is() {
748+ let db = acme();
749+ db.execute_batch(
750+ "UPDATE work_items SET horizon = 'now', updated_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3, 4);
751+ INSERT INTO decisions (id, repo_id, item_id, kind, confidence, summary, created_at)
752+ VALUES ('dec_1', 'rep_web', 'wki_3', 'proposal', 'approved', 'Rank by fractional keys', '2026-10-10T00:00:00Z'),
753+ ('dec_2', 'rep_web', 'wki_1', 'claim', 'approved', 'Scope: tests', '2026-10-11T00:00:00Z');
754+ INSERT INTO sessions (id, repo_id, actor_id, actor_name, harness, state, started_at, last_seen_at, item_id)
755+ VALUES ('ses_1', 'rep_web', 'usr_1', 'ana', 'claude_code', 'active', '2026-10-12T00:00:00Z', '2026-10-15T00:00:00Z', 'wki_4');
756+ INSERT INTO session_touches (session_id, repo_id, kind, value, at) VALUES ('ses_1', 'rep_web', 'commit', 'abc1234', '2026-10-15T00:00:00Z');",
757+ )
758+ .unwrap();
759+ // A claim's note is not a decision; nothing else has happened on #1.
760+ let one = facts(&db, 1);
761+ assert_eq!((one.hill(), one.active_at.as_deref()), (Hill::FiguringOut, Some("2026-10-11T00:00:00Z")));
762+ assert_eq!(facts(&db, 3).hill(), Hill::Decided);
763+ let four = facts(&db, 4);
764+ assert_eq!((four.hill(), four.active_at.as_deref()), (Hill::Building, Some("2026-10-15T00:00:00Z")));
765+ // Quiet: nothing since before the line.
766+ assert!(facts(&db, 3).progress("2026-10-14T00:00:00Z").quiet);
767+ assert!(!facts(&db, 4).progress("2026-10-14T00:00:00Z").quiet);
768+ }
769+
770+ #[test]
771+ fn the_plan_says_what_waits_too_long() {
772+ let db = acme();
773+ db.execute_batch(
774+ "UPDATE work_items SET created_at = '2026-10-01T00:00:00Z' WHERE number IN (1, 3);
775+ UPDATE work_items SET created_at = '2026-10-18T00:00:00Z' WHERE number = 4;
776+ UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-10-15T00:00:00Z' WHERE number = 5;
777+ UPDATE work_items SET horizon = 'set_aside', planned_at = '2026-08-01T00:00:00Z' WHERE number = 6;",
778+ )
779+ .unwrap();
780+ let values: [&dyn ToSql; 6] = [&"acme", &1, &"[]", &None::<String>, &"2026-10-12T00:00:00Z", &"2026-09-20T00:00:00Z"];
781+ let (waiting, recent): (u32, u32) = db
782+ .query_row(&WAITING.replace("{OPEN_VISIBLE}", OPEN_VISIBLE), &values[..], |row| Ok((row.get(0)?, row.get(1)?)))
783+ .unwrap();
784+ // #1 and #3 (and #2, made with no date) in Triage since before the line; #5 set aside lately.
785+ assert_eq!((waiting, recent), (3, 1));
786+ }
787+
598788 #[test]
599789 fn a_move_says_what_changed() {
600790 assert_eq!(move_note(Horizon::Triage, Horizon::Next, false, false).as_deref(), Some("moved this from Triage to Next"));
+419−0
1+//! The plan reads itself (migrations/0057_plan_reads_itself.sql), on the
2+//! service's cron, a little at a time:
3+//!
4+//! - A work item in Triage gets a suggestion of where it goes: a horizon,
5+//! an appetite, an open work item it seems to repeat, and why, asked once
6+//! of the workspace's own AI provider through integrations' `ai_brief`
7+//! (g1t runs no model of its own). A person accepts it or not.
8+//! - A work item in Now gets a sentence or two on where it stands, drafted
9+//! from its record (sessions, decisions, pull requests) whenever
10+//! something new has happened on it.
11+//! - A work item in Later with nothing happening on it for
12+//! [`SET_ASIDE_DAYS`] is set aside, saying so in its conversation.
13+//!
14+//! A workspace with no provider chosen gets no suggestions and no drafts;
15+//! the rest of the plan works the same without them.
16+
17+use std::collections::HashSet;
18+
19+use g1t_contracts::ai::{AiBriefArgs, MAX_BRIEF_INPUT_CHARS};
20+use g1t_contracts::events::WorkItemEvent;
21+use g1t_contracts::time::rfc3339;
22+use g1t_contracts::work::*;
23+use g1t_contracts::Outcome;
24+use g1t_kit::now_ms;
25+use serde::Deserialize;
26+use worker::Result;
27+
28+use super::plan::days_before;
29+use crate::Work;
30+
31+/// The most the provider is asked in one run of the cron, across
32+/// workspaces: suggestions and drafts together.
33+const CALLS_PER_RUN: u32 = 12;
34+/// The most set aside in one run.
35+const SET_ASIDE_PER_RUN: u32 = 50;
36+/// The most of the plan a suggestion is asked with.
37+const PLAN_LINES: u32 = 80;
38+/// The longest suggestion reason and standing kept, in characters.
39+const WHY_CHARS: usize = 240;
40+const STANDING_CHARS: usize = 320;
41+
42+const SUGGEST_SYSTEM: &str = "You place a new work item in a software team's plan. \
43+The horizons: now (what the team is on; only for something urgent or blocking), \
44+next (ranked, taken soon), later (wanted, not soon). \
45+The appetites: small (one session), medium (a few sessions, about a week), large (a phase to break into smaller items). \
46+If it repeats one of the open work items listed, give that one's number. \
47+Answer in exactly four lines and nothing else:\n\
48+horizon: now|next|later\n\
49+appetite: small|medium|large\n\
50+duplicate: <number or none>\n\
51+why: <one plain sentence>";
52+
53+const STANDING_SYSTEM: &str = "From the record given, say where this piece of work stands now and what comes next, \
54+in one or two plain sentences. No preamble, no lists, no headings. Name pull requests and work items as #N.";
55+
56+/// What one run may still ask the provider, and the workspaces that have
57+/// none (asked again on the next run).
58+struct Budget {
59+ calls: u32,
60+ without: HashSet<String>,
61+}
62+
63+/// A suggestion read from the provider's answer: none when it does not
64+/// say a horizon it may suggest. `known` are the open work items it may
65+/// say this one repeats.
66+pub(crate) fn read_suggestion(answer: &str, known: &[u32], at: &str) -> Option<PlanSuggestion> {
67+ let mut horizon = None;
68+ let mut appetite = None;
69+ let mut duplicate_of = None;
70+ let mut why = String::new();
71+ for line in answer.lines() {
72+ let Some((key, value)) = line.split_once(':') else {
73+ continue;
74+ };
75+ let value = value.trim().trim_matches(|c: char| c == '*' || c == '`' || c == '"').trim();
76+ match key.trim().trim_matches(|c: char| c == '*' || c == '-').trim().to_ascii_lowercase().as_str() {
77+ "horizon" => horizon = Horizon::parse(value).filter(|h| matches!(h, Horizon::Now | Horizon::Next | Horizon::Later)),
78+ "appetite" => appetite = Appetite::parse(value),
79+ "duplicate" => {
80+ duplicate_of = value.trim_start_matches('#').parse::<u32>().ok().filter(|number| known.contains(number));
81+ }
82+ "why" => why = clip(value, WHY_CHARS),
83+ _ => {}
84+ }
85+ }
86+ Some(PlanSuggestion { horizon: horizon?, appetite, duplicate_of, why, at: at.to_owned() })
87+}
88+
89+/// `text` on one line, at most `limit` characters, cut at a word.
90+pub(crate) fn clip(text: &str, limit: usize) -> String {
91+ let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
92+ if flat.chars().count() <= limit {
93+ return flat;
94+ }
95+ let cut: String = flat.chars().take(limit).collect();
96+ let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(head, _)| head).trim_end_matches([',', ';', ':', '.']);
97+ format!("{cut}…")
98+}
99+
100+/// A drafted standing as kept: one paragraph, bounded; none when the
101+/// answer has nothing in it.
102+pub(crate) fn read_standing(answer: &str) -> Option<String> {
103+ // A list marker or bold the model put in front, never a #N it begins with.
104+ let trimmed = answer.trim();
105+ let trimmed = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed).trim_matches('*');
106+ let text = clip(trimmed, STANDING_CHARS);
107+ (!text.is_empty()).then_some(text)
108+}
109+
110+#[derive(Deserialize)]
111+struct Pending {
112+ id: String,
113+ workspace: String,
114+ number: u32,
115+ title: String,
116+ body: String,
117+ #[serde(default)]
118+ origin: Option<String>,
119+}
120+
121+#[derive(Deserialize)]
122+struct PlanLine {
123+ number: u32,
124+ horizon: String,
125+ title: String,
126+}
127+
128+#[derive(Deserialize)]
129+struct Line {
130+ #[serde(default)]
131+ title: Option<String>,
132+ #[serde(default)]
133+ state: Option<String>,
134+ #[serde(default)]
135+ result: Option<String>,
136+ #[serde(default)]
137+ summary: Option<String>,
138+ #[serde(default)]
139+ number: Option<u32>,
140+ #[serde(default)]
141+ status: Option<String>,
142+}
143+
144+#[derive(Deserialize)]
145+struct Stale {
146+ id: String,
147+}
148+
149+impl Work {
150+ /// One run of the plan's upkeep: sets aside what has waited too long in
151+ /// Later, then suggests and drafts within the run's budget.
152+ pub(crate) async fn settle_plan(&self) -> Result<()> {
153+ self.set_aside_stale().await?;
154+ let mut budget = Budget { calls: CALLS_PER_RUN, without: HashSet::new() };
155+ self.suggest_triage(&mut budget).await?;
156+ self.draft_standings(&mut budget).await?;
157+ Ok(())
158+ }
159+
160+ /// Asks the workspace's provider, within `budget`. None when it has no
161+ /// provider, or could not answer (said in the log).
162+ async fn brief(&self, workspace: &str, system: &str, input: String, max_tokens: u32, budget: &mut Budget) -> Option<String> {
163+ if budget.calls == 0 || budget.without.contains(workspace) {
164+ return None;
165+ }
166+ let integrations = self.integrations.as_ref()?;
167+ budget.calls -= 1;
168+ let input: String = input.chars().take(MAX_BRIEF_INPUT_CHARS).collect();
169+ let args = AiBriefArgs { workspace: workspace.to_owned(), system: system.to_owned(), input, max_tokens };
170+ match g1t_kit::call::<_, Outcome<Option<String>>>(integrations, "ai_brief", &args).await {
171+ Ok(Outcome::Ok(Some(answer))) => Some(answer),
172+ Ok(Outcome::Ok(None)) => {
173+ budget.without.insert(workspace.to_owned());
174+ None
175+ }
176+ Ok(Outcome::Fail(failure)) => {
177+ worker::console_error!("{workspace}: the plan got no answer from the AI provider: {}", failure.message);
178+ budget.without.insert(workspace.to_owned());
179+ None
180+ }
181+ Err(error) => {
182+ worker::console_error!("{workspace}: the plan got no answer from the AI provider: {error}");
183+ budget.without.insert(workspace.to_owned());
184+ None
185+ }
186+ }
187+ }
188+
189+ /// Sets aside what has had nothing happen on it in Later for
190+ /// [`SET_ASIDE_DAYS`].
191+ async fn set_aside_stale(&self) -> Result<()> {
192+ let now = now_ms();
193+ let before = days_before(now, SET_ASIDE_DAYS);
194+ let stale = self
195+ .db
196+ .prepare(
197+ "SELECT id FROM work_items
198+ WHERE state = 'open' AND horizon = 'later' AND number IS NOT NULL
199+ AND max(updated_at, COALESCE(planned_at, '')) < ?1
200+ LIMIT ?2",
201+ )
202+ .bind(&[before.as_str().into(), SET_ASIDE_PER_RUN.into()])?
203+ .all()
204+ .await?
205+ .results::<Stale>()?;
206+ let at = rfc3339(now);
207+ for row in stale {
208+ self.db
209+ .prepare("UPDATE work_items SET horizon = 'set_aside', rank = NULL, planned_at = ?1 WHERE id = ?2 AND horizon = 'later'")
210+ .bind(&[at.as_str().into(), row.id.as_str().into()])?
211+ .run()
212+ .await?;
213+ let Some(held) = self.item_by_id(&row.id).await? else {
214+ continue;
215+ };
216+ self.publish_item(
217+ "work_item.planned",
218+ &held,
219+ None,
220+ WorkItemEvent {
221+ horizon: Some(Horizon::SetAside),
222+ from_horizon: Some(Horizon::Later),
223+ appetite: held.item.appetite,
224+ urgent: Some(false),
225+ ..Self::item_event(&held)
226+ },
227+ )
228+ .await?;
229+ self.note_item(
230+ &row.id,
231+ (g1t_contracts::identity::AGENT_ID, "g1t"),
232+ &format!("set this aside: nothing happened on it in Later for {SET_ASIDE_DAYS} days"),
233+ )
234+ .await?;
235+ }
236+ Ok(())
237+ }
238+
239+ /// Suggests where new work items in Triage go, the newest first.
240+ async fn suggest_triage(&self, budget: &mut Budget) -> Result<()> {
241+ let pending = self
242+ .db
243+ .prepare(
244+ "SELECT w.id, w.workspace, w.number, w.title, w.body,
245+ (SELECT COALESCE(rw.path, o.path) FROM work_item_repos o LEFT JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
246+ WHERE o.item_id = w.id AND o.role = 'origin') AS origin
247+ FROM work_items w
248+ WHERE w.state = 'open' AND COALESCE(w.horizon, 'triage') = 'triage' AND w.suggested_at IS NULL
249+ AND w.number IS NOT NULL
250+ ORDER BY w.created_at DESC LIMIT ?1",
251+ )
252+ .bind(&[budget.calls.into()])?
253+ .all()
254+ .await?
255+ .results::<Pending>()?;
256+ for item in pending {
257+ if budget.calls == 0 {
258+ break;
259+ }
260+ let plan = self
261+ .db
262+ .prepare(
263+ "SELECT number, COALESCE(horizon, 'triage') AS horizon, title FROM work_items
264+ WHERE workspace = ?1 AND state = 'open' AND number IS NOT NULL AND id <> ?2
265+ ORDER BY CASE COALESCE(horizon, 'triage') WHEN 'now' THEN 0 WHEN 'next' THEN 1 WHEN 'later' THEN 2
266+ WHEN 'triage' THEN 3 ELSE 4 END, rank IS NULL, rank, number DESC
267+ LIMIT ?3",
268+ )
269+ .bind(&[item.workspace.as_str().into(), item.id.as_str().into(), PLAN_LINES.into()])?
270+ .all()
271+ .await?
272+ .results::<PlanLine>()?;
273+ let known: Vec<u32> = plan.iter().map(|line| line.number).collect();
274+ let listed: Vec<String> = plan.iter().map(|line| format!("#{} [{}] {}", line.number, line.horizon, line.title)).collect();
275+ let input = format!(
276+ "New work item #{}{}: {}\n\n{}\n\nOpen work items:\n{}",
277+ item.number,
278+ item.origin.as_deref().map(|path| format!(" (filed in {path})")).unwrap_or_default(),
279+ item.title,
280+ clip(&item.body, 1_500),
281+ listed.join("\n")
282+ );
283+ let at = rfc3339(now_ms());
284+ let answer = self.brief(&item.workspace, SUGGEST_SYSTEM, input, 120, budget).await;
285+ if answer.is_none() && !budget.without.contains(&item.workspace) {
286+ // Out of calls for this run: asked again on the next.
287+ break;
288+ }
289+ let suggestion = answer.as_deref().and_then(|answer| read_suggestion(answer, &known, &at));
290+ // Asked once: with no provider, or no usable answer, it is not
291+ // asked again.
292+ self.db
293+ .prepare("UPDATE work_items SET suggestion = ?1, suggested_at = ?2 WHERE id = ?3")
294+ .bind(&[
295+ suggestion.as_ref().map(serde_json::to_string).transpose()?.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
296+ at.as_str().into(),
297+ item.id.as_str().into(),
298+ ])?
299+ .run()
300+ .await?;
301+ }
302+ Ok(())
303+ }
304+
305+ /// Drafts where work items in Now stand, those with something new
306+ /// since their last draft first.
307+ async fn draft_standings(&self, budget: &mut Budget) -> Result<()> {
308+ #[derive(Deserialize)]
309+ struct Due {
310+ id: String,
311+ workspace: String,
312+ number: u32,
313+ title: String,
314+ }
315+ let due = self
316+ .db
317+ .prepare(
318+ "SELECT id, workspace, number, title FROM (
319+ SELECT w.id, w.workspace, w.number, w.title, w.standing_at,
320+ max(COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.item_id = w.id), ''),
321+ COALESCE((SELECT max(d.created_at) FROM decisions d WHERE d.item_id = w.id), ''),
322+ COALESCE((SELECT max(p.updated_at) FROM pulls p WHERE p.item_id = w.id), '')) AS active_at
323+ FROM work_items w WHERE w.state = 'open' AND w.horizon = 'now' AND w.number IS NOT NULL)
324+ WHERE active_at <> '' AND (standing_at IS NULL OR standing_at < active_at)
325+ ORDER BY active_at DESC LIMIT ?1",
326+ )
327+ .bind(&[budget.calls.into()])?
328+ .all()
329+ .await?
330+ .results::<Due>()?;
331+ for item in due {
332+ if budget.calls == 0 {
333+ break;
334+ }
335+ let key = [worker::wasm_bindgen::JsValue::from(item.id.as_str())];
336+ let statements = vec![
337+ self.db
338+ .prepare("SELECT title, state, result FROM sessions WHERE item_id = ?1 ORDER BY last_seen_at DESC LIMIT 5")
339+ .bind(&key)?,
340+ self.db.prepare("SELECT summary FROM decisions WHERE item_id = ?1 ORDER BY created_at DESC LIMIT 5").bind(&key)?,
341+ self.db.prepare("SELECT number, status, title FROM pulls WHERE item_id = ?1 ORDER BY created_at DESC LIMIT 5").bind(&key)?,
342+ ];
343+ let results = self.db.batch(statements).await?;
344+ let lines = |index: usize| -> Result<Vec<Line>> {
345+ results.get(index).map_or_else(|| Ok(Vec::new()), |result| result.results::<Line>())
346+ };
347+ let sessions: Vec<String> = lines(0)?
348+ .into_iter()
349+ .map(|line| {
350+ let result = line.result.as_deref().map(|text| format!(": {}", clip(text, 300))).unwrap_or_default();
351+ format!("- {} ({}){result}", line.title.unwrap_or_else(|| "Untitled session".into()), line.state.unwrap_or_default())
352+ })
353+ .collect();
354+ let decisions: Vec<String> = lines(1)?.into_iter().filter_map(|line| line.summary).map(|summary| format!("- {}", clip(&summary, 300))).collect();
355+ let pulls: Vec<String> = lines(2)?
356+ .into_iter()
357+ .map(|line| format!("- #{} {} ({})", line.number.unwrap_or_default(), line.title.unwrap_or_default(), line.status.unwrap_or_default()))
358+ .collect();
359+ let none = || "- none".to_owned();
360+ let input = format!(
361+ "Work item #{}: {}\n\nSessions, newest first:\n{}\n\nDecisions, newest first:\n{}\n\nPull requests:\n{}",
362+ item.number,
363+ item.title,
364+ if sessions.is_empty() { none() } else { sessions.join("\n") },
365+ if decisions.is_empty() { none() } else { decisions.join("\n") },
366+ if pulls.is_empty() { none() } else { pulls.join("\n") },
367+ );
368+ let answer = self.brief(&item.workspace, STANDING_SYSTEM, input, 120, budget).await;
369+ if answer.is_none() && !budget.without.contains(&item.workspace) {
370+ break;
371+ }
372+ // With no provider, the time is still kept, so it is asked again
373+ // only when something new happens.
374+ let standing = answer.as_deref().and_then(read_standing);
375+ self.db
376+ .prepare("UPDATE work_items SET standing = COALESCE(?1, standing), standing_at = ?2 WHERE id = ?3")
377+ .bind(&[
378+ standing.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
379+ rfc3339(now_ms()).into(),
380+ item.id.as_str().into(),
381+ ])?
382+ .run()
383+ .await?;
384+ }
385+ Ok(())
386+ }
387+}
388+
389+#[cfg(test)]
390+mod tests {
391+ use super::*;
392+
393+ #[test]
394+ fn a_suggestion_is_read_from_the_four_lines() {
395+ let at = "2026-10-20T00:00:00Z";
396+ let answer = "horizon: next\nappetite: small\nduplicate: #12\nwhy: It is a small fix to Home that others wait on.";
397+ let read = read_suggestion(answer, &[12, 14], at).unwrap();
398+ assert_eq!(read.horizon, Horizon::Next);
399+ assert_eq!(read.appetite, Some(Appetite::Small));
400+ assert_eq!(read.duplicate_of, Some(12));
401+ assert_eq!(read.why, "It is a small fix to Home that others wait on.");
402+ // Markdown around it, and a number not in the plan.
403+ let read = read_suggestion("**Horizon:** Later\n- Appetite: L\nDuplicate: 99\nWhy: Big.", &[12], at).unwrap();
404+ assert_eq!((read.horizon, read.appetite, read.duplicate_of), (Horizon::Later, Some(Appetite::Large), None));
405+ // A horizon it may not suggest, or none: no suggestion.
406+ assert_eq!(read_suggestion("horizon: set_aside\nwhy: old", &[], at), None);
407+ assert_eq!(read_suggestion("I think this should go next.", &[], at), None);
408+ }
409+
410+ #[test]
411+ fn what_is_kept_is_one_bounded_line() {
412+ assert_eq!(clip("Two words\nhere", 50), "Two words here");
413+ let long = "word ".repeat(100);
414+ let clipped = clip(&long, 30);
415+ assert!(clipped.chars().count() <= 31 && clipped.ends_with('…'), "{clipped}");
416+ assert_eq!(read_standing(" \n "), None);
417+ assert_eq!(read_standing("- #205 merged; the plan is live. Next: place the backlog.").as_deref(), Some("#205 merged; the plan is live. Next: place the backlog."));
418+ }
419+}
+9−0
8282 pub urgent: u8,
8383 #[serde(default)]
8484 pub planned_at: Option<String>,
85+ /// JSON [`PlanSuggestion`] (plan_settle.rs).
86+ #[serde(default)]
87+ pub suggestion: Option<String>,
8588 }
8689
8790 /// A link as [`ITEM_COLUMNS`] reads it.
288291 budget_usd: self.budget_usd,
289292 urgent: self.urgent != 0,
290293 planned_at: self.planned_at,
294+ // Only while it waits to be placed.
295+ suggestion: self
296+ .suggestion
297+ .as_deref()
298+ .filter(|_| self.horizon.unwrap_or_default() == Horizon::Triage && self.state == State::Open)
299+ .and_then(|json| serde_json::from_str(json).ok()),
291300 },
292301 origin,
293302 known,
+7−0
Loading the diff…