@hanzo/build 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,9 @@ the database, the sign-in and the storage already there.
5
5
 
6
6
  The builder is one component, `<Builder host={…} />` from `@hanzo/build`, with
7
7
  three hosts: this page (hanzo.build), and the Dev section of the Hanzo app at
8
- hanzo.ai/dev and hanzo.app/dev. None of them forks it.
8
+ hanzo.ai/dev and hanzo.app/dev. None of them forks it. hanzo.build is this
9
+ frontend. A click stays on this origin — sign-in returns to `/auth/callback`
10
+ here — and github.com is opened only for the repository grant.
9
11
 
10
12
  ```
11
13
  pnpm install
@@ -67,6 +69,11 @@ hanzo.id token for `Host.admin`. One left column, never two.
67
69
  |---|---|
68
70
  | `''` | New — the empty state and the composer |
69
71
  | `sess_<32 hex>` | one run, live |
72
+ | `-/automations` | repeating work, read from `/v1/auto/flows` |
73
+ | `-/codebases` | the forge's repositories |
74
+ | `-/sync` | bring granted repositories onto the forge |
75
+ | `-/projects` | the forge's boards |
76
+ | `-/issues` | the forge's issues |
70
77
  | `-/artifacts` | what the org has built |
71
78
  | `-/templates` | the public starters |
72
79
  | `<slug>` | a project's workspace |
@@ -76,16 +83,19 @@ collide.
76
83
 
77
84
  ## What is on the screen
78
85
 
79
- **The rail** (hanzo.build). New, Artifacts, Customize (the platform's
80
- plugins), More (Templates, Machines, Docs), then the org's coding runs newest
81
- first with a live status dot, and the account. Collapse is an explicit toggle
82
- kept in this browser. A host with its own rail draws `DevSection` there instead.
86
+ **The rail** (hanzo.build). New, then Automations, then the forge — Codebase, Projects, Issues —
87
+ then Artifacts, Customize (the platform's plugins), More (Templates, Machines,
88
+ Docs), then the org's coding runs newest first with a live status dot, and the
89
+ account. Collapse is an explicit toggle kept in this browser. A host with its
90
+ own rail draws `DevSection` there instead.
83
91
 
84
92
  **New.** "What's up next?", and at the foot the composer: where the run runs
85
- (Default is the platform's sandbox; the org's machines follow), the repository
86
- and the branch — both searchable, paged popovers opening upward — then the
93
+ (Default is the platform's sandbox; the org's machines follow), the codebase
94
+ and the branch — the forge's repositories, filtered as you type — then the
87
95
  ask. Under it: attach (files ride the prompt as text), dictate, Build or Plan,
88
- and the model and effort. A repository row can be added to a project.
96
+ and the model and effort. A codebase can be added to a project. Opening a
97
+ codebase or an issue from its own screen lands here with that choice already
98
+ made.
89
99
 
90
100
  **A run.** The transcript as it streams, steering while it works, Stop, and
91
101
  the pull request once it pushes one.
@@ -104,7 +114,11 @@ picker and open-in-tab; Share and Publish; the console dock under it.
104
114
  | `GET /v1/agent/sessions/{id}` · `GET /v1/agent/sessions/stream?root=` | a run, and its live feed (SSE over fetch) |
105
115
  | `POST /v1/agent/sessions/{id}/message` · `/stop` | steer, stop |
106
116
  | `GET /v1/agent/targets` | the org's machines |
107
- | `GET /v1/provider/github/repos` · `…/{owner}/{repo}/branches` | the chips' pagers |
117
+ | `GET /v1/auto/flows` · `POST /v1/auto/flows` · `POST /v1/auto/flows/{id}/enable` | automations |
118
+ | `GET /v1/provider/github/repos` · `POST /v1/provider/github/repos/import` | granted repositories, and bringing them onto the forge |
119
+ | `GET /v1/git/repos` · `GET /v1/git/repos/{name}` | the codebase chip, and its branches |
120
+ | `GET /v1/task/projects` · `GET /v1/task/board` · `GET /v1/task/projects/{key}/issues` | boards and issues, read from the forge |
121
+ | `GET /v1/provider/github/repos` · `…/{owner}/{repo}/branches` | kept for a host that still asks GitHub |
108
122
  | `POST /v1/provider/github/user/connect` | connect a person's GitHub |
109
123
  | `GET /v1/projects` · `POST /v1/projects/fork` · `GET /v1/templates` | artifacts, templates |
110
124
  | `GET /v1/git/repos/{name}/tree` · `/blob` | Files and Code |
@@ -121,6 +135,7 @@ src/
121
135
  index.ts the library surface
122
136
  builder.tsx the rail and the pane the address names
123
137
  landing.tsx New
138
+ forge.tsx Codebase, Automations, Projects, Issues
124
139
  run.tsx one run
125
140
  project.tsx a project's workspace
126
141
  shelf.tsx Artifacts and Templates
package/lib/account.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { RailSession } from '@hanzo/ui/chat';
2
+ /** The builder listens, so a settings control on this page opens the account dialog. */
3
+ export declare function onAccount(fn: () => void): () => void;
4
+ export declare function revealAccount(): void;
2
5
  export declare function Account({ open, onOpenChange }: {
3
6
  open: boolean;
4
7
  onOpenChange: (o: boolean) => void;
package/lib/account.js CHANGED
@@ -8,10 +8,33 @@ import { LogOut, Settings } from '@hanzogui/lucide-icons-2';
8
8
  import { Button, Dialog, DialogContent, DialogTitle, Input } from '@hanzo/ui';
9
9
  import { useMemo, useState } from 'react';
10
10
  import { useHost } from './host.js';
11
+ let reveal = null;
12
+ /** The builder listens, so a settings control on this page opens the account dialog. */
13
+ export function onAccount(fn) {
14
+ reveal = fn;
15
+ return () => {
16
+ if (reveal === fn)
17
+ reveal = null;
18
+ };
19
+ }
20
+ export function revealAccount() {
21
+ reveal?.();
22
+ }
23
+ /** A settings address on another host. This page's own account is the dialog itself. */
24
+ function accountSettings(href) {
25
+ if (typeof window === 'undefined')
26
+ return false;
27
+ try {
28
+ return new URL(href, window.location.origin).origin !== window.location.origin;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
11
34
  export function Account({ open, onOpenChange }) {
12
35
  const host = useHost();
13
36
  const who = host.person;
14
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 360, children: [_jsx(DialogTitle, { children: "Account" }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: who?.name || who?.email || 'Signed out' }), who?.email && who.name ? (_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, children: who.email })) : null, host.org ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Organization: ", host.org] })) : null] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", flexWrap: "wrap", children: [_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.open(host.links.settings), children: [_jsx(Settings, { size: 14 }), " Settings"] }), host.signOut ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.signOut?.(), children: [_jsx(LogOut, { size: 14 }), " Sign out"] })) : null] })] }) }));
37
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 360, children: [_jsx(DialogTitle, { children: "Account" }), _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", numberOfLines: 1, children: who?.name || who?.email || 'Signed out' }), who?.email && who.name ? (_jsx(SizableText, { size: "$2", color: "$soft", numberOfLines: 1, children: who.email })) : null, host.org ? (_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Organization: ", host.org] })) : null] }), _jsxs(XStack, { gap: "$2", justify: "flex-end", flexWrap: "wrap", children: [accountSettings(host.links.settings) ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.open(host.links.settings), children: [_jsx(Settings, { size: 14 }), " Settings"] })) : null, host.signOut ? (_jsxs(Button, { variant: "outline", size: "sm", onPress: () => host.signOut?.(), children: [_jsx(LogOut, { size: 14 }), " Sign out"] })) : null] })] }) }));
15
38
  }
16
39
  export function Find({ open, onOpenChange, recents, onOpen, }) {
17
40
  const [q, setQ] = useState('');
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Automations: flows that run themselves.
3
+ *
4
+ * GET /v1/auto/flows {data: [flow]} newest updated first
5
+ * GET /v1/auto/flows/{id} the flow and its latest version (the name)
6
+ * POST /v1/auto/flows a disabled draft
7
+ * POST /v1/auto/flows/{id}/enable
8
+ * POST /v1/auto/flows/{id}/disable
9
+ *
10
+ * The list page records the id and whether the trigger is armed. The name lives
11
+ * on the latest version, so each row is read once more.
12
+ */
13
+ import { type Target } from './call.ts';
14
+ export interface Automation {
15
+ id: string;
16
+ name: string;
17
+ /** ENABLED or DISABLED. */
18
+ status: string;
19
+ /** Unix milliseconds, or 0. */
20
+ updated: number;
21
+ }
22
+ export declare function automation(raw: unknown): Automation | null;
23
+ export declare function flows(t: Target): Promise<Automation[]>;
24
+ /** A disabled draft. Creating it does not arm the trigger. */
25
+ export declare function add(t: Target, name: string): Promise<Automation>;
26
+ /** Arm or disarm the trigger. A disarmed flow can still be started on demand. */
27
+ export declare function arm(t: Target, id: string, on: boolean): Promise<void>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Automations: flows that run themselves.
3
+ *
4
+ * GET /v1/auto/flows {data: [flow]} newest updated first
5
+ * GET /v1/auto/flows/{id} the flow and its latest version (the name)
6
+ * POST /v1/auto/flows a disabled draft
7
+ * POST /v1/auto/flows/{id}/enable
8
+ * POST /v1/auto/flows/{id}/disable
9
+ *
10
+ * The list page records the id and whether the trigger is armed. The name lives
11
+ * on the latest version, so each row is read once more.
12
+ */
13
+ import { call, seg } from './call.js';
14
+ const str = (v) => (typeof v === 'string' ? v : '');
15
+ const obj = (v) => v && typeof v === 'object' ? v : {};
16
+ export function automation(raw) {
17
+ const o = obj(raw);
18
+ const id = str(o.id);
19
+ if (!id)
20
+ return null;
21
+ const version = obj(o.version);
22
+ return {
23
+ id,
24
+ name: str(version.displayName) || str(o.displayName) || id,
25
+ status: str(o.status) || 'DISABLED',
26
+ updated: typeof o.updated === 'number' ? o.updated : 0,
27
+ };
28
+ }
29
+ export async function flows(t) {
30
+ const raw = await call(t, 'GET', '/v1/auto/flows');
31
+ const data = Array.isArray(obj(raw).data) ? obj(raw).data : [];
32
+ const rows = data.map(automation).filter((a) => a !== null).slice(0, 40);
33
+ return Promise.all(rows.map(async (a) => {
34
+ if (a.name !== a.id)
35
+ return a;
36
+ try {
37
+ return automation(await call(t, 'GET', `/v1/auto/flows/${seg(a.id)}`)) ?? a;
38
+ }
39
+ catch {
40
+ return a;
41
+ }
42
+ }));
43
+ }
44
+ /** A disabled draft. Creating it does not arm the trigger. */
45
+ export async function add(t, name) {
46
+ const displayName = name.trim();
47
+ if (!displayName)
48
+ throw new Error('Name the automation');
49
+ const raw = await call(t, 'POST', '/v1/auto/flows', {
50
+ displayName,
51
+ trigger: {
52
+ name: 'trigger',
53
+ type: 'PIECE_TRIGGER',
54
+ displayName: 'Start',
55
+ strategy: 'MANUAL',
56
+ settings: { pieceName: 'core', triggerName: 'manual' },
57
+ },
58
+ });
59
+ const row = automation(raw);
60
+ if (!row)
61
+ throw new Error('The automation was created and did not come back named');
62
+ return row;
63
+ }
64
+ /** Arm or disarm the trigger. A disarmed flow can still be started on demand. */
65
+ export async function arm(t, id, on) {
66
+ await call(t, 'POST', `/v1/auto/flows/${seg(id)}/${on ? 'enable' : 'disable'}`);
67
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Codebases on the forge, the org's own git.
3
+ *
4
+ * GET /v1/git/repos {data: [repo]} most recently updated first
5
+ * GET /v1/git/repos/{name} one repo, with its branch names
6
+ *
7
+ * A coding run addresses a repository by its name. The org is the tenant on
8
+ * the request, so the name is the whole address — `owner/name` is a path the
9
+ * engine refuses.
10
+ */
11
+ import { type Target } from './call.ts';
12
+ export interface Codebase {
13
+ org: string;
14
+ name: string;
15
+ description: string;
16
+ /** HEAD's branch, or 'main' when the forge did not say. */
17
+ branch: string;
18
+ public: boolean;
19
+ /** The https remote `git clone` takes, or ''. */
20
+ clone: string;
21
+ updated: string;
22
+ branches: string[];
23
+ }
24
+ /** What the composer chip stores. `forge` marks a choice made from this list. */
25
+ export interface ForgeRepo {
26
+ owner: string;
27
+ name: string;
28
+ full_name: string;
29
+ private: boolean;
30
+ default_branch: string;
31
+ pushed_at: string;
32
+ installation_id: number;
33
+ forge: true;
34
+ clone: string;
35
+ }
36
+ export declare function codebase(raw: unknown, org?: string): Codebase | null;
37
+ /** A chip row remembered as a forge codebase, when the list had not seen it yet. */
38
+ export declare function chosen(r: {
39
+ owner: string;
40
+ name: string;
41
+ full_name?: string;
42
+ private?: boolean;
43
+ default_branch?: string;
44
+ }, clone?: string): ForgeRepo;
45
+ /** The chip's row for a codebase the forge listed. */
46
+ export declare function asRepo(c: Codebase): ForgeRepo;
47
+ /** Provision an empty repository. The name is the handle and the last path segment. */
48
+ export declare function create(t: Target, name: string, description?: string): Promise<Codebase>;
49
+ export declare function codebases(t: Target, q?: string): Promise<Codebase[]>;
50
+ /** One codebase, so the branch chip can list what HEAD can point at. */
51
+ export declare function one(t: Target, name: string): Promise<Codebase | null>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Codebases on the forge, the org's own git.
3
+ *
4
+ * GET /v1/git/repos {data: [repo]} most recently updated first
5
+ * GET /v1/git/repos/{name} one repo, with its branch names
6
+ *
7
+ * A coding run addresses a repository by its name. The org is the tenant on
8
+ * the request, so the name is the whole address — `owner/name` is a path the
9
+ * engine refuses.
10
+ */
11
+ import { call, seg } from './call.js';
12
+ const str = (v) => (typeof v === 'string' ? v : '');
13
+ const obj = (v) => v && typeof v === 'object' ? v : {};
14
+ export function codebase(raw, org = '') {
15
+ const o = obj(raw);
16
+ const name = str(o.name);
17
+ if (!name)
18
+ return null;
19
+ const branches = (Array.isArray(o.branches) ? o.branches : []).filter((b) => typeof b === 'string' && b !== '');
20
+ const branch = str(o.defaultBranch) || 'main';
21
+ return {
22
+ org: str(o.org) || org,
23
+ name,
24
+ description: str(o.description),
25
+ branch,
26
+ public: o.public === true,
27
+ clone: str(o.cloneUrl),
28
+ updated: str(o.updatedAt),
29
+ branches: branches.length ? branches : [branch],
30
+ };
31
+ }
32
+ /** A chip row remembered as a forge codebase, when the list had not seen it yet. */
33
+ export function chosen(r, clone = '') {
34
+ return {
35
+ owner: r.owner,
36
+ name: r.name,
37
+ full_name: r.full_name || (r.owner ? `${r.owner}/${r.name}` : r.name),
38
+ private: r.private === true,
39
+ default_branch: r.default_branch || 'main',
40
+ pushed_at: '',
41
+ installation_id: 0,
42
+ forge: true,
43
+ clone,
44
+ };
45
+ }
46
+ /** The chip's row for a codebase the forge listed. */
47
+ export function asRepo(c) {
48
+ const owner = c.org;
49
+ return {
50
+ owner,
51
+ name: c.name,
52
+ full_name: owner ? `${owner}/${c.name}` : c.name,
53
+ private: !c.public,
54
+ default_branch: c.branch || 'main',
55
+ pushed_at: c.updated,
56
+ installation_id: 0,
57
+ forge: true,
58
+ clone: c.clone,
59
+ };
60
+ }
61
+ function rows(raw) {
62
+ if (Array.isArray(raw))
63
+ return raw;
64
+ const o = obj(raw);
65
+ return Array.isArray(o.data) ? o.data : [];
66
+ }
67
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
68
+ /** Provision an empty repository. The name is the handle and the last path segment. */
69
+ export async function create(t, name, description = '') {
70
+ const handle = name.trim().replace(/\.git$/, '');
71
+ if (!NAME.test(handle))
72
+ throw new Error('A repository name starts with a letter or number, and may contain letters, numbers, dots, underscores and hyphens.');
73
+ const raw = await call(t, 'POST', '/v1/git/repos', { name: handle, description: description.trim() });
74
+ const row = codebase(raw, t.org ?? '');
75
+ if (!row)
76
+ throw new Error('The forge created a repository and did not name it');
77
+ return row;
78
+ }
79
+ export async function codebases(t, q = '') {
80
+ const raw = await call(t, 'GET', '/v1/git/repos');
81
+ const needle = q.trim().toLowerCase();
82
+ return rows(raw)
83
+ .map((r) => codebase(r, t.org ?? ''))
84
+ .filter((c) => c !== null)
85
+ .filter((c) => !needle || `${c.org}/${c.name} ${c.description}`.toLowerCase().includes(needle));
86
+ }
87
+ /** One codebase, so the branch chip can list what HEAD can point at. */
88
+ export async function one(t, name) {
89
+ const raw = await call(t, 'GET', `/v1/git/repos/${seg(name)}`);
90
+ return codebase(raw, t.org ?? '');
91
+ }
@@ -26,7 +26,7 @@ export declare const unhonoured: (mode: Mode | undefined, target?: string) => st
26
26
  export interface Ask {
27
27
  /** The task, in the words you would use with a colleague. */
28
28
  prompt: string;
29
- /** `owner/name`. Omitted starts something new, in a repository named for it. */
29
+ /** The repository's name in the caller's org. A slash is not a name. Omitted starts something new. */
30
30
  repo?: string;
31
31
  /** The branch to start from. Omitted takes the repository's default. */
32
32
  base?: string;
@@ -76,3 +76,34 @@ export declare function connection(t: Target): Promise<Connection>;
76
76
  /** Where to send the person to authorize the platform's GitHub App — github.com only. */
77
77
  export declare function connect(t: Target): Promise<string>;
78
78
  export declare function disconnect(t: Target): Promise<void>;
79
+ /** One repository the GitHub connection grants this organization. */
80
+ export interface Grant {
81
+ owner: string;
82
+ name: string;
83
+ /** `owner/name`, the selector the importer accepts. */
84
+ fullName: string;
85
+ private: boolean;
86
+ branch: string;
87
+ /** Already mirrored onto the forge. */
88
+ imported: boolean;
89
+ /** `synced`, `conflict`, or '' while it has not landed. */
90
+ status: string;
91
+ }
92
+ export interface Grants {
93
+ repos: Grant[];
94
+ /** Accounts the connection could not read. */
95
+ unread: string[];
96
+ }
97
+ export interface Account {
98
+ name: string;
99
+ count: number;
100
+ /** Named as unread and carrying no repositories. */
101
+ blocked: boolean;
102
+ }
103
+ export declare function grant(raw: unknown): Grant | null;
104
+ /** Accounts the grant names, plus any account that could not be read. */
105
+ export declare function accounts(g: Grants): Account[];
106
+ /** Every repository the GitHub connection grants, with whether the forge already has it. */
107
+ export declare function grants(t: Target, signal?: AbortSignal): Promise<Grants>;
108
+ /** Queue a mirror of the named repositories onto the forge. The names are `owner/name`. */
109
+ export declare function bring(t: Target, repos: string[]): Promise<number>;
package/lib/api/github.js CHANGED
@@ -71,3 +71,53 @@ export async function connect(t) {
71
71
  export async function disconnect(t) {
72
72
  await call(t, 'POST', '/v1/provider/github/user/disconnect');
73
73
  }
74
+ export function grant(raw) {
75
+ const r = obj(raw);
76
+ const name = str(r.name);
77
+ if (!name)
78
+ return null;
79
+ const full = str(r.fullName) || str(r.full_name);
80
+ const owner = str(r.owner) || (full.includes('/') ? full.slice(0, full.indexOf('/')) : '');
81
+ return {
82
+ owner,
83
+ name,
84
+ fullName: full || (owner ? `${owner}/${name}` : name),
85
+ private: r.private === true,
86
+ branch: str(r.defaultBranch) || str(r.default_branch) || 'main',
87
+ imported: r.imported === true,
88
+ status: str(r.syncStatus) || str(r.sync_status),
89
+ };
90
+ }
91
+ /** Accounts the grant names, plus any account that could not be read. */
92
+ export function accounts(g) {
93
+ const counts = new Map();
94
+ for (const r of g.repos) {
95
+ if (!r.owner)
96
+ continue;
97
+ counts.set(r.owner, (counts.get(r.owner) ?? 0) + 1);
98
+ }
99
+ const unread = new Set(g.unread.filter(Boolean));
100
+ const names = new Set([...counts.keys(), ...unread]);
101
+ return [...names]
102
+ .sort((a, b) => a.localeCompare(b))
103
+ .map((name) => {
104
+ const count = counts.get(name) ?? 0;
105
+ return { name, count, blocked: unread.has(name) && count === 0 };
106
+ });
107
+ }
108
+ /** Every repository the GitHub connection grants, with whether the forge already has it. */
109
+ export async function grants(t, signal) {
110
+ const raw = obj(await call(t, 'GET', '/v1/provider/github/repos', undefined, { signal }));
111
+ return {
112
+ repos: (Array.isArray(raw.repos) ? raw.repos : []).map(grant).filter((r) => r !== null),
113
+ unread: (Array.isArray(raw.unread) ? raw.unread : []).filter((x) => typeof x === 'string' && x !== ''),
114
+ };
115
+ }
116
+ /** Queue a mirror of the named repositories onto the forge. The names are `owner/name`. */
117
+ export async function bring(t, repos) {
118
+ const names = repos.map((n) => n.trim()).filter(Boolean);
119
+ if (names.length === 0)
120
+ throw new Error('Choose a repository first');
121
+ const raw = obj(await call(t, 'POST', '/v1/provider/github/repos/import', { repos: names }));
122
+ return num(raw.queued);
123
+ }
package/lib/api/turn.d.ts CHANGED
@@ -21,6 +21,25 @@ export declare function decode(payload: unknown): Record<string, unknown> | stri
21
21
  * the run's, from its record: a plan run's final status says the plan.
22
22
  */
23
23
  export declare function said(e: Pick<Event, 'kind' | 'payload'>, mode?: string): string;
24
+ /** One line of the shell: a command the run started, or output that followed it. */
25
+ export interface ShellLine {
26
+ role: 'cmd' | 'out';
27
+ text: string;
28
+ }
29
+ /**
30
+ * The shell beside the transcript. A tool call is the command; a log line, and
31
+ * any stdout or stderr the payload carried, is the output. Status stays in the
32
+ * transcript. Each line is capped so a payload that carried a file body cannot
33
+ * fill the pane.
34
+ */
35
+ export declare function shell(events: Pick<Event, 'kind' | 'payload' | 'seq'>[]): ShellLine[];
36
+ /** A step the run named. Done is a step whose status has settled. */
37
+ export interface StepLine {
38
+ name: string;
39
+ done: boolean;
40
+ }
41
+ /** Tool calls, in the order they were first named. A later status for the same step wins. */
42
+ export declare function steps(events: Pick<Event, 'kind' | 'payload' | 'seq'>[]): StepLine[];
24
43
  export interface Outcome {
25
44
  /** The last lifecycle status the run narrated, or ''. */
26
45
  status: string;
package/lib/api/turn.js CHANGED
@@ -80,6 +80,66 @@ export function said(e, mode = '') {
80
80
  return str(body.error) || 'The run hit an error';
81
81
  return str(body.step) || str(body.name);
82
82
  }
83
+ /**
84
+ * The shell beside the transcript. A tool call is the command; a log line, and
85
+ * any stdout or stderr the payload carried, is the output. Status stays in the
86
+ * transcript. Each line is capped so a payload that carried a file body cannot
87
+ * fill the pane.
88
+ */
89
+ export function shell(events) {
90
+ const cap = 4000;
91
+ const clip = (s) => (s.length > cap ? `${s.slice(0, cap)}…` : s);
92
+ const out = [];
93
+ const push = (role, text) => {
94
+ const line = clip(text);
95
+ if (line)
96
+ out.push({ role, text: line });
97
+ };
98
+ for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
99
+ if (e.kind !== 'log' && e.kind !== 'tool-call')
100
+ continue;
101
+ const body = decode(e.payload);
102
+ if (typeof body === 'string') {
103
+ push('out', body);
104
+ continue;
105
+ }
106
+ if (!body)
107
+ continue;
108
+ const step = str(body.step);
109
+ const message = str(body.message) || str(body.text) || str(body.command);
110
+ const stdout = str(body.stdout);
111
+ const stderr = str(body.stderr);
112
+ if (e.kind === 'tool-call' && step)
113
+ push('cmd', step);
114
+ if (message && message !== step)
115
+ push('out', message);
116
+ if (stdout)
117
+ push('out', stdout);
118
+ if (stderr)
119
+ push('out', stderr);
120
+ }
121
+ return out.slice(-400);
122
+ }
123
+ /** Tool calls, in the order they were first named. A later status for the same step wins. */
124
+ export function steps(events) {
125
+ const order = [];
126
+ const done = new Map();
127
+ for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
128
+ if (e.kind !== 'tool-call')
129
+ continue;
130
+ const body = decode(e.payload);
131
+ if (!body || typeof body === 'string')
132
+ continue;
133
+ const name = str(body.step);
134
+ if (!name)
135
+ continue;
136
+ if (!done.has(name))
137
+ order.push(name);
138
+ const status = str(body.status);
139
+ done.set(name, status === 'ok' || status === 'done' || status === 'error');
140
+ }
141
+ return order.map((name) => ({ name, done: done.get(name) === true }));
142
+ }
83
143
  /**
84
144
  * What the run narrated about how it ended, latest winning. Status text only:
85
145
  * the branch and the pull request are read from the run's record (`pull`).
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Projects and issues, as the forge holds them.
3
+ *
4
+ * A project is a board. A board is a repository that has work on it, so the
5
+ * list is the forge's answer and not a second copy of it.
6
+ *
7
+ * GET /v1/task/projects the boards
8
+ * GET /v1/task/board every board's issues
9
+ * GET /v1/task/projects/{key}/issues one board's issues
10
+ *
11
+ * Creating, renaming and deleting a board are forge operations. This surface
12
+ * reads the work and opens a run against it.
13
+ */
14
+ import { type Target } from './call.ts';
15
+ export interface Board {
16
+ id: string;
17
+ key: string;
18
+ name: string;
19
+ description: string;
20
+ }
21
+ export interface Work {
22
+ id: string;
23
+ /** `<key>#<number>`, the handle a person reads. */
24
+ identifier: string;
25
+ project: string;
26
+ number: number;
27
+ kind: string;
28
+ title: string;
29
+ status: string;
30
+ priority: string;
31
+ assignee: string;
32
+ /** The repository the item is bound to, or ''. */
33
+ repo: string;
34
+ }
35
+ export declare function board(raw: unknown): Board | null;
36
+ export declare function work(raw: unknown): Work | null;
37
+ export declare function boards(t: Target): Promise<Board[]>;
38
+ /** A board's issues, or every board's when `key` is empty. */
39
+ export declare function issues(t: Target, key?: string): Promise<Work[]>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Projects and issues, as the forge holds them.
3
+ *
4
+ * A project is a board. A board is a repository that has work on it, so the
5
+ * list is the forge's answer and not a second copy of it.
6
+ *
7
+ * GET /v1/task/projects the boards
8
+ * GET /v1/task/board every board's issues
9
+ * GET /v1/task/projects/{key}/issues one board's issues
10
+ *
11
+ * Creating, renaming and deleting a board are forge operations. This surface
12
+ * reads the work and opens a run against it.
13
+ */
14
+ import { call, seg } from './call.js';
15
+ const str = (v) => (typeof v === 'string' ? v : '');
16
+ const obj = (v) => v && typeof v === 'object' ? v : {};
17
+ function rows(raw) {
18
+ if (Array.isArray(raw))
19
+ return raw;
20
+ const o = obj(raw);
21
+ if (Array.isArray(o.data))
22
+ return o.data;
23
+ if (Array.isArray(o.issues))
24
+ return o.issues;
25
+ return [];
26
+ }
27
+ export function board(raw) {
28
+ const o = obj(raw);
29
+ const key = str(o.key);
30
+ if (!key)
31
+ return null;
32
+ return { id: str(o.id) || key, key, name: str(o.name) || key, description: str(o.description) };
33
+ }
34
+ export function work(raw) {
35
+ const o = obj(raw);
36
+ const title = str(o.title);
37
+ const project = str(o.projectKey);
38
+ const number = typeof o.number === 'number' ? o.number : 0;
39
+ if (!title && !project)
40
+ return null;
41
+ return {
42
+ id: str(o.id) || `${project}#${number}`,
43
+ identifier: str(o.identifier) || (project && number ? `${project}#${number}` : ''),
44
+ project,
45
+ number,
46
+ kind: str(o.kind) || 'issue',
47
+ title: title || 'Untitled',
48
+ status: str(o.status) || 'backlog',
49
+ priority: str(o.priority) || 'none',
50
+ assignee: str(o.assignee),
51
+ repo: str(o.repo),
52
+ };
53
+ }
54
+ export async function boards(t) {
55
+ const raw = await call(t, 'GET', '/v1/task/projects');
56
+ return rows(raw)
57
+ .map(board)
58
+ .filter((b) => b !== null);
59
+ }
60
+ /** A board's issues, or every board's when `key` is empty. */
61
+ export async function issues(t, key = '') {
62
+ const path = key ? `/v1/task/projects/${seg(key)}/issues` : '/v1/task/board';
63
+ const raw = await call(t, 'GET', path);
64
+ return rows(raw)
65
+ .map(work)
66
+ .filter((w) => w !== null);
67
+ }