@open-autonomy/sdk 2.1.0

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 ADDED
@@ -0,0 +1,123 @@
1
+ # @open-autonomy/sdk
2
+
3
+ The Open Autonomy SDK: how a project reports its own development to the platform, and the roadmap model
4
+ every kit writes. Everything a project's page shows about its agent comes through this wire — the sessions,
5
+ the roadmap, the board, the agent's setup — and nothing through the platform reading a harness's files, so
6
+ any substrate can be a project: the Hermes kit and the file roadmap are starters, not the shape. Everything
7
+ here is one documented HTTP wire, shown raw below, so any language can do the same without this package.
8
+ The Hermes kit vendors it into a generated repository under `.open-autonomy/sdk/`.
9
+
10
+ ```ts
11
+ import { OpenAutonomy } from '@open-autonomy/sdk';
12
+
13
+ const oa = new OpenAutonomy({ baseUrl: 'https://open-autonomy.org/v1', key: process.env.OPEN_AUTONOMY_KEY! });
14
+ const s = await oa.open({ key: 'a3f9c1d2', kind: 'run', source: 'board', item: 'add' });
15
+ await s.turns([{ role: 'assistant', tool: 'terminal', args: '{"command":"bun run check"}' }, { role: 'tool', tool: 'terminal', result: 'ok' }]);
16
+ await oa.update({ item: 'add', text: 'the store writes; the id counter next', session: s.key });
17
+ await s.end({ outcome: 'done', report: 'Done. add — committed 7d30729.', commit: '7d30729' });
18
+ ```
19
+
20
+ ## The model
21
+
22
+ - **A session** is one agent conversation: a `kind` (`run` is a scheduled run, the funded work; `chat`
23
+ anything else), the roadmap `item` it serves when known, a `source` (the schedule job's name, a channel).
24
+ It opens, its turns append with an offset, and it ends with an optional outcome: a run has a verdict
25
+ (`done` | `failed`), a chat does not. Several can be live at once.
26
+ - **An update** is a short progress note on a roadmap item, optionally from a session.
27
+ - **The roadmap** is one normalized document per project, whatever holds it natively: a board, a tracker, a file. This package reads the file form into a typed shape and writes
28
+ it back byte for byte (`parseRoadmap`, `serializeRoadmap`, `withStatus`, `renderRoadmap`); the platform's
29
+ page parses through the same code. There is no write API: the file in git is the only roadmap surface.
30
+ Adapters that mirror it to a tracker are what the shape is for.
31
+
32
+ Spend is attributed by the platform: a metered call settles on the one session live at that moment, so
33
+ an item's page shows every session, update and settled cent that touched it.
34
+
35
+ ## Drivers
36
+
37
+ The platform holds one normalized roadmap per project, revisioned: who, when, from which source, what
38
+ changed. Substrates feed it: a reporter publishing its board, or a driver. `file` is a roadmap file in git pulled on sync. `github-milestones` is the
39
+ repository's milestones, pulled on sync with no credential (`fromMilestones`). `jira` is the project's
40
+ epics, read owner-side where the credential is and pushed with `pushRoadmap` on a `steer`-scoped key
41
+ (`fromJira`). Each driver declares its conformance, what its tracker cannot express (`CONFORMANCE`), and a
42
+ reconcile plan carries a finished item back to the native side (`milestoneChanges`, `jiraChanges`). The
43
+ agent is tracker-blind: whatever the source, it works its own queue and narrates the item and its outcome.
44
+
45
+ | Route | What |
46
+ |---|---|
47
+ | `GET /v1/accounts/:account/roadmap` | the current revision: `revision`, `ts`, `source`, `by`, `roadmap`, `changes`, `conformance` |
48
+ | `GET /v1/accounts/:account/roadmap/revisions?limit=` | the history, newest first |
49
+ | `POST /v1/agent/roadmap` `{ source, roadmap, by? }` | an owner-side push; needs the `steer` scope; an unchanged roadmap is not a revision |
50
+
51
+ ## Rails
52
+
53
+ Money leaves an account only through a metered rail, and every rail leaves a record on the audit trail
54
+ naming itself. The model rail is a stock OpenAI or Anthropic SDK pointed at the platform. The two others
55
+ are bounded by the owner in `.open-autonomy/config.yaml` (`parseRailsConfig`) and off until a bound is set:
56
+
57
+ | Route | What |
58
+ |---|---|
59
+ | `POST /v1/rails/card` `{ usd_cents, purpose? }` | a single-use virtual card minted against the balance (Stripe Issuing), bounded to the amount and the owner's merchant categories; returns the card's `id`, `last4`, expiry, and `number`/`cvc` where the issuer exposes them. A merchant's authorization is decided in real time, its capture settles as a `card` record (merchant, category, last4), and the card is retired |
60
+ | `GET /v1/keys/challenge?funder=<login>` → `POST /v1/keys/mint {funder, repo}` | a funder's key: the claim file in a repository the login owns proves the login; the key can only give |
61
+ | `POST /v1/grants/give` `{ to, usd_cents, note?, key? }` (a give key) | grant credits from the funder's books to a project's: money in for the project, `Granted by @login` on its page, once per `key` |
62
+ | `GET /v1/funders/:login` | a funder's public books: credits to give (and how much of it is the org's bonus, for other people's projects), given, received |
63
+ | `POST /v1/patrons/checkout` `{ account: "@login", tier, interval: "once" }` | a funder buys a credit pack through Polar; the org matches a share as bonus credits |
64
+ | `POST /v1/rails/partner` `{ partner, usd_cents, unit?, quantity?, reference? }` | a partner service's metered charge, settled now as a `partner` record, for a partner the owner listed and within the amount the owner set |
65
+
66
+ Key scopes: `spend` (the rails), `narrate` (the stream), `steer` (a roadmap push). A key minted without
67
+ `scopes` carries spend and narrate; `POST /v1/keys/mint {account, scopes: ["steer"]}` mints a driver's key
68
+ that spends nothing.
69
+
70
+ ## The wire
71
+
72
+ All narration is `POST /v1/agent/events` with the project's key as `Authorization: Bearer <key>`, a body
73
+ of one CloudEvents 1.0 event or a JSON array of them, applied in order. Secret-shaped text is redacted at
74
+ intake; everything accepted is public.
75
+
76
+ ```json
77
+ [{ "specversion": "1.0", "id": "…", "source": "my-reporter", "time": "2026-09-04T00:20:11Z",
78
+ "type": "org.open-autonomy.session.started", "subject": "<session key>",
79
+ "data": { "session_kind": "run", "source": "board", "title": "…", "item_id": "add" } },
80
+ { "specversion": "1.0", "id": "…", "source": "my-reporter", "time": "…",
81
+ "type": "org.open-autonomy.session.turns", "subject": "<session key>",
82
+ "data": { "seq": 0, "item_id": "add", "turns": [
83
+ { "ts": "…", "role": "user", "text": "…" },
84
+ { "ts": "…", "role": "assistant", "tool": "terminal", "args": "{…}" },
85
+ { "ts": "…", "role": "tool", "tool": "terminal", "result": "…" },
86
+ { "ts": "…", "role": "assistant", "text": "…" } ] } },
87
+ { "specversion": "1.0", "id": "…", "source": "my-reporter", "time": "…",
88
+ "type": "org.open-autonomy.item.update", "subject": "<item id>",
89
+ … }
90
+ { "type": "org.open-autonomy.item.task", "subject": "<item id>",
91
+ "data": { "task_id": "…", "lane": "review", "attempts": [{ "id": "1", "profile": "default", "status": "review_requested", "summary": "…" }], "reviews": [{ "verdict": "requested" }], "handoff": { "summary": "…" } } }
92
+ { "type": "org.open-autonomy.agent.setup", "subject": "agent",
93
+ "data": { "harness": "hermes", "persona": "…", "model": "zai/glm-5.3-flash", "schedule": [{ "name": "file-roadmap-item", "schedule": "every 360m" }], "skills": ["roadmap", "land"], "setup_md": "…" }
94
+ "data": { "text": "…", "session": "<session key>" } },
95
+ { "specversion": "1.0", "id": "…", "source": "my-reporter", "time": "…",
96
+ "type": "org.open-autonomy.session.ended", "subject": "<session key>",
97
+ "data": { "outcome": "done", "report": "…", "commit_sha": "7d30729", "item_id": "add", "ended_at": "…" } }]
98
+ ```
99
+
100
+ `seq` is the offset of the first turn in the session's own order: a retry or a reconnect that replays
101
+ offsets already applied is ignored (`idempotent: true` in that event's result), so a reporter that restarts
102
+ reads the session back and continues from its `next_seq`. The response is `{ ok, results: [{ id, ok,
103
+ session | update, idempotent?, error? }] }`; the first failing event stops the batch.
104
+
105
+ Public reads, no key:
106
+
107
+ | Route | What |
108
+ |---|---|
109
+ | `GET /v1/accounts/:account/sessions?limit=` | the stream, newest first, and `live`: the keys live now |
110
+ | `GET /v1/accounts/:account/sessions/:key` | one session with its transcript tail and `next_seq` |
111
+ | `GET /v1/accounts/:account/sessions/:key/events` | Server-Sent Events: `turn` (id = offset), `status`; `Last-Event-ID` resumes |
112
+ | `GET /v1/accounts/:account/items/:item` | every session, update and settled cent on the item |
113
+ | `GET /v1/accounts/:account/items/:item/events` | Server-Sent Events: `item` on change, until nothing is live |
114
+ | `POST /v1/agent/events` with type `org.open-autonomy.project.docs` `{ about_md?, shipped_md? }` | the project's documents, from whatever files the substrate keeps: what it is (the page leads with the first paragraph), what shipped |
115
+ | `GET /v1/accounts/:account/events` | Server-Sent Events: `project` on change (the books, the live set, the roadmap revision); stays open |
116
+ | `GET /v1/accounts/:account` | the books: balance, spend, runway |
117
+ | `GET /v1/accounts/:account/calls?limit=&before=` | the audit trail, every metered spend, newest first |
118
+
119
+ Keys, the adopter way: `GET /v1/keys/challenge?account=owner/repo` names a claim to commit to
120
+ `.open-autonomy-claim` on the default branch; `POST /v1/keys/mint {account, models?}` mints once the file
121
+ is at HEAD; `POST /v1/keys/rotate` with the current key mints a successor and leaves the old one a day of
122
+ grace. A key is verified by its signature and expiry alone, so it survives every redeploy; the platform's
123
+ registry can only revoke it or shorten it.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@open-autonomy/sdk",
3
+ "version": "2.1.0",
4
+ "description": "The Open Autonomy SDK: the roadmap model and its codec, the development-stream client (sessions, turns, updates), and the key helpers. Everything it does is a documented HTTP wire any language can speak without it.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/open-autonomy-org/open-autonomy.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./roadmap": "./src/roadmap.ts",
15
+ "./client": "./src/client.ts",
16
+ "./drivers": "./src/drivers.ts",
17
+ "./rails": "./src/rails.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test test/*.test.ts",
28
+ "typecheck": "bunx tsc --noEmit",
29
+ "check": "bun test test/*.test.ts && bunx tsc --noEmit"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "^1.3.10",
33
+ "typescript": "^5.9.0"
34
+ }
35
+ }
package/src/client.ts ADDED
@@ -0,0 +1,235 @@
1
+ // The development-stream client: how a project reports its own work to the platform, on its key. Every
2
+ // method is one HTTP call whose raw form is documented in the README, so any language can do the same
3
+ // without this package.
4
+ //
5
+ // Sessions are conversations: opened with a kind (`run` is a scheduled run, the funded work; `chat`
6
+ // anything else) and, when known, the roadmap item they serve; turns append with an offset so a retry or
7
+ // a reconnect is idempotent; a session ends with an optional outcome. Updates are short progress notes on
8
+ // an item. All of it goes to POST /v1/agent/events as CloudEvents 1.0, one or a batch.
9
+
10
+ import type { Roadmap } from './roadmap.ts';
11
+
12
+ export interface ClientOptions {
13
+ baseUrl: string; // e.g. https://open-autonomy.org/v1 (the key valve's forwarded address inside a stack)
14
+ key: string;
15
+ fetch?: typeof fetch;
16
+ }
17
+
18
+ export type TurnRole = 'user' | 'assistant' | 'tool' | 'system';
19
+ export interface Turn { ts?: string; role: TurnRole; text?: string; tool?: string; args?: string; result?: string }
20
+ export type SessionOutcome = 'done' | 'failed';
21
+
22
+ export interface SessionStart { key: string; kind?: string; title?: string; item?: string; source?: string; startedAt?: string }
23
+ export interface SessionEnd { key: string; outcome?: SessionOutcome; report?: string; commit?: string; item?: string; endedAt?: string }
24
+ export interface Update { item: string; text: string; session?: string; at?: string }
25
+ // The board's state for a roadmap item, as the agent's harness keeps it: the task's lane, every attempt at
26
+ // it, the handoff and the review verdicts. Published by the reporter from the harness's own board.
27
+ export interface TaskAttempt { id: string; profile?: string; status: string; started_at?: string; ended_at?: string; outcome?: string; summary?: string }
28
+ export interface TaskReview { verdict: 'requested' | 'approved' | 'changes_requested' | 'escalated'; by?: string; reason?: string; at?: string }
29
+ export interface TaskState { item: string; task_id: string; lane: string; title?: string; assignee?: string; attempts: TaskAttempt[]; reviews: TaskReview[]; handoff?: { summary?: string; metadata?: unknown }; updated_at?: string }
30
+ export const TASK_EVENT_TYPE = 'org.open-autonomy.item.task';
31
+ // Who the agent is and how it runs, as its substrate publishes it: a persona (the identity text it runs
32
+ // with), its model, its schedule, what it knows how to do, and how to run it. The platform shows this
33
+ // beside the roadmap; it reads no harness's files for it.
34
+ export interface AgentSetup { harness?: string; persona?: string; model?: string; provider?: string; schedule?: Array<{ name: string; schedule: string; description?: string }>; skills?: string[]; setup_md?: string }
35
+ export const SETUP_EVENT_TYPE = 'org.open-autonomy.agent.setup';
36
+ // The project's documents, from whatever files the substrate keeps: what the project is (`about_md`; the page
37
+ // leads with its first paragraph) and what shipped (`shipped_md`). Each field replaces what was there.
38
+ export interface ProjectDocs { about_md?: string; shipped_md?: string }
39
+ export const DOCS_EVENT_TYPE = 'org.open-autonomy.project.docs';
40
+
41
+ export interface CloudEvent {
42
+ specversion: '1.0';
43
+ id: string;
44
+ source: string;
45
+ type: string;
46
+ subject: string;
47
+ time: string;
48
+ datacontenttype: 'application/json';
49
+ data: Record<string, unknown>;
50
+ }
51
+
52
+ export const EVENT_TYPES = {
53
+ started: 'org.open-autonomy.session.started',
54
+ turns: 'org.open-autonomy.session.turns',
55
+ ended: 'org.open-autonomy.session.ended',
56
+ update: 'org.open-autonomy.item.update',
57
+ } as const;
58
+
59
+ export function sessionStartedEvent(s: SessionStart, source = 'open-autonomy-sdk'): CloudEvent {
60
+ return event(EVENT_TYPES.started, s.key, { session_kind: s.kind, title: s.title, item_id: s.item, source: s.source }, s.startedAt, source);
61
+ }
62
+ export function sessionTurnsEvent(key: string, seq: number, turns: Turn[], item?: string, source = 'open-autonomy-sdk'): CloudEvent {
63
+ return event(EVENT_TYPES.turns, key, { seq, turns, item_id: item }, undefined, source);
64
+ }
65
+ export function sessionEndedEvent(e: SessionEnd, source = 'open-autonomy-sdk'): CloudEvent {
66
+ return event(EVENT_TYPES.ended, e.key, { outcome: e.outcome, report: e.report, commit_sha: e.commit, item_id: e.item, ended_at: e.endedAt }, e.endedAt, source);
67
+ }
68
+ export function updateEvent(u: Update, source = 'open-autonomy-sdk'): CloudEvent {
69
+ return event(EVENT_TYPES.update, u.item, { text: u.text, session: u.session }, u.at, source);
70
+ }
71
+
72
+ function event(type: string, subject: string, data: Record<string, unknown>, time?: string, source = 'open-autonomy-sdk'): CloudEvent {
73
+ return { specversion: '1.0', id: crypto.randomUUID(), source, type, subject, time: time ?? new Date().toISOString(), datacontenttype: 'application/json', data: Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) };
74
+ }
75
+
76
+ export interface EventResult { id?: string; ok: boolean; error?: string; idempotent?: boolean; session?: SessionSummary; update?: UpdateRecord }
77
+ export interface SessionSummary {
78
+ key: string; account: string; kind: string; status: 'live' | 'ended'; outcome?: SessionOutcome; title?: string; item_id?: string; source?: string;
79
+ started_at: string; ended_at?: string; report?: string; commit_sha?: string; turn_count: number; next_seq: number; tool_calls: number; usd_cents: number; calls: number; updated_at: string;
80
+ }
81
+ export interface SessionRecord extends Omit<SessionSummary, 'tool_calls'> { turns: Array<Turn & { seq?: number }> }
82
+ export interface UpdateRecord { id: string; account: string; item_id: string; ts: string; text: string; session?: string }
83
+ export interface ItemView { ok: true; account: string; item_id: string; live: string[]; sessions: SessionSummary[]; updates: UpdateRecord[]; usd_cents: number }
84
+
85
+ export class OpenAutonomy {
86
+ private readonly base: string;
87
+ private readonly fetchImpl: typeof fetch;
88
+ constructor(private readonly opts: ClientOptions) {
89
+ this.base = opts.baseUrl.replace(/\/$/, '');
90
+ this.fetchImpl = opts.fetch ?? fetch;
91
+ }
92
+
93
+ // POST /v1/agent/events (Authorization: Bearer <key>; body: one CloudEvent or an array)
94
+ async send(events: CloudEvent | CloudEvent[]): Promise<{ ok: boolean; status: number; results: EventResult[] }> {
95
+ const res = await this.fetchImpl(`${this.base}/agent/events`, {
96
+ method: 'POST',
97
+ headers: { authorization: `Bearer ${this.opts.key}`, 'content-type': 'application/cloudevents-batch+json' },
98
+ body: JSON.stringify(Array.isArray(events) ? events : [events]),
99
+ });
100
+ const body = await res.json().catch(() => ({})) as { ok?: boolean; results?: EventResult[] };
101
+ return { ok: res.ok && body.ok === true, status: res.status, results: body.results ?? [] };
102
+ }
103
+
104
+ // A session, as a small object that remembers its offset. `resume` reads the platform's own offset first,
105
+ // so a reporter that restarts mid-session continues where the books are rather than replaying.
106
+ async open(start: SessionStart): Promise<Session> {
107
+ const r = await this.send(sessionStartedEvent(start));
108
+ if (!r.ok) throw new Error(`open ${start.key}: ${r.status} ${r.results[0]?.error ?? ''}`);
109
+ return new Session(this, start.key, r.results[0]?.session?.next_seq ?? 0);
110
+ }
111
+
112
+ async resume(key: string, account: string, fallback: SessionStart): Promise<Session> {
113
+ const existing = await this.session(account, key);
114
+ if (existing) return new Session(this, key, existing.next_seq);
115
+ return this.open(fallback);
116
+ }
117
+
118
+ async update(u: Update): Promise<UpdateRecord | undefined> {
119
+ const r = await this.send(updateEvent(u));
120
+ return r.results[0]?.update;
121
+ }
122
+
123
+ // A grant: credits from this funder's books to a project's, once per idempotency key, with a word.
124
+ async give(g: { to: string; usd_cents: number; note?: string; key?: string }): Promise<{ ok: boolean; error?: string; from?: string; to_balance_usd_cents?: number; from_balance_usd_cents?: number }> {
125
+ const res = await this.fetchImpl(`${this.base}/grants/give`, { method: 'POST', headers: { authorization: `Bearer ${this.opts.key}`, 'content-type': 'application/json' }, body: JSON.stringify(g) });
126
+ return await res.json() as { ok: boolean; error?: string };
127
+ }
128
+
129
+ // The agent's setup, replacing what was there.
130
+ async setup(s: AgentSetup): Promise<boolean> {
131
+ const r = await this.send(event(SETUP_EVENT_TYPE, 'agent', s as unknown as Record<string, unknown>));
132
+ return r.results[0]?.ok === true;
133
+ }
134
+
135
+ // The project's documents, replacing what was there.
136
+ async docs(d: ProjectDocs): Promise<boolean> {
137
+ const r = await this.send(event(DOCS_EVENT_TYPE, 'project', d as unknown as Record<string, unknown>));
138
+ return r.results[0]?.ok === true;
139
+ }
140
+
141
+ // The board's state for an item: the task's lane, attempts, handoff and reviews, replacing what was there.
142
+ async task(t: TaskState): Promise<boolean> {
143
+ const { item, ...data } = t;
144
+ const r = await this.send(event(TASK_EVENT_TYPE, item, data as unknown as Record<string, unknown>, data.updated_at));
145
+ return r.results[0]?.ok === true;
146
+ }
147
+
148
+ // Public reads (no key): the stream, one session with its transcript, one item with everything on it.
149
+ async sessions(account: string, limit = 30): Promise<{ live: string[]; sessions: SessionSummary[] }> {
150
+ const res = await this.fetchImpl(`${this.base}/accounts/${encodeURIComponent(account)}/sessions?limit=${limit}`);
151
+ return await res.json() as { live: string[]; sessions: SessionSummary[] };
152
+ }
153
+ async session(account: string, key: string): Promise<SessionRecord | undefined> {
154
+ const res = await this.fetchImpl(`${this.base}/accounts/${encodeURIComponent(account)}/sessions/${encodeURIComponent(key)}`);
155
+ if (!res.ok) return undefined;
156
+ return ((await res.json()) as { session?: SessionRecord }).session;
157
+ }
158
+ async item(account: string, itemId: string): Promise<ItemView> {
159
+ const res = await this.fetchImpl(`${this.base}/accounts/${encodeURIComponent(account)}/items/${encodeURIComponent(itemId)}`);
160
+ return await res.json() as ItemView;
161
+ }
162
+
163
+ // The roadmap as the platform holds it: the current normalized revision, and its history.
164
+ // GET /v1/accounts/:account/roadmap GET /v1/accounts/:account/roadmap/revisions?limit=
165
+ async roadmap(account: string): Promise<RoadmapRevision | undefined> {
166
+ const res = await this.fetchImpl(`${this.base}/accounts/${encodeURIComponent(account)}/roadmap`);
167
+ if (!res.ok) return undefined;
168
+ return ((await res.json()) as { revision?: RoadmapRevision }).revision;
169
+ }
170
+ async roadmapRevisions(account: string, limit = 20): Promise<RoadmapRevision[]> {
171
+ const res = await this.fetchImpl(`${this.base}/accounts/${encodeURIComponent(account)}/roadmap/revisions?limit=${limit}`);
172
+ return ((await res.json()) as { revisions?: RoadmapRevision[] }).revisions ?? [];
173
+ }
174
+ // An owner-side driver pushes the normalized roadmap it pulled from its tracker. Needs the `steer` scope,
175
+ // which a spending key does not carry.
176
+ // POST /v1/agent/roadmap (Authorization: Bearer <steer key>) { source, roadmap, by? }
177
+ async pushRoadmap(roadmap: Roadmap, source: string, by?: string): Promise<{ ok: boolean; status: number; revision?: RoadmapRevision; unchanged?: boolean; error?: string }> {
178
+ const res = await this.fetchImpl(`${this.base}/agent/roadmap`, { method: 'POST', headers: { authorization: `Bearer ${this.opts.key}`, 'content-type': 'application/json' }, body: JSON.stringify({ source, roadmap, by }) });
179
+ const body = await res.json().catch(() => ({})) as { ok?: boolean; revision?: RoadmapRevision; unchanged?: boolean; error?: { code?: string } };
180
+ return { ok: res.ok && body.ok === true, status: res.status, revision: body.revision, unchanged: body.unchanged, error: body.error?.code };
181
+ }
182
+ }
183
+
184
+ export interface RoadmapRevision {
185
+ revision: number;
186
+ ts: string;
187
+ source: string;
188
+ by?: string;
189
+ roadmap: Roadmap;
190
+ changes: Array<{ id: string; kind: 'added' | 'removed' | 'status' | 'edited'; from?: string; to?: string }>;
191
+ conformance: string[];
192
+ }
193
+
194
+ export class Session {
195
+ constructor(private readonly client: OpenAutonomy, readonly key: string, public seq: number) {}
196
+ async turns(turns: Turn[], item?: string): Promise<void> {
197
+ if (!turns.length) return;
198
+ const r = await this.client.send(sessionTurnsEvent(this.key, this.seq, turns, item));
199
+ if (r.ok) this.seq += turns.length;
200
+ }
201
+ async end(end: Omit<SessionEnd, 'key'> = {}): Promise<void> {
202
+ await this.client.send(sessionEndedEvent({ ...end, key: this.key }));
203
+ }
204
+ }
205
+
206
+ // Key helpers: the adopter way, with no admin token anywhere. Prove control of the repository by
207
+ // committing the claim the platform names, then mint; rotate with the current key.
208
+ export interface KeyChallenge { ok: boolean; account: string; file: string; claim: string; valid_through: string }
209
+ export interface MintedKey { ok: boolean; token: string; key: { kid: string; account: string; models: string[]; iat: string; exp: string }; previous?: { kid: string; exp: string } }
210
+
211
+ export async function keyChallenge(baseUrl: string, account: string, fetchImpl: typeof fetch = fetch): Promise<KeyChallenge> {
212
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/keys/challenge?account=${encodeURIComponent(account)}`);
213
+ return await res.json() as KeyChallenge;
214
+ }
215
+ // `scopes` picks what the key may do (the platform's default is spend + narrate): a treasurer's key adds `pay`,
216
+ // the rails; an owner-side driver's is `steer` alone.
217
+ export async function keyMint(baseUrl: string, account: string, models?: string[], scopes?: string[], fetchImpl: typeof fetch = fetch): Promise<MintedKey> {
218
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/keys/mint`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account, models, ...(scopes ? { scopes } : {}) }) });
219
+ return await res.json() as MintedKey;
220
+ }
221
+ // `graceSeconds` shortens how long the old key keeps working (the platform's default is a day; it never lengthens).
222
+ // A funder: a person who holds grant credits on their own books (`@login`). Their key proves their GitHub
223
+ // login through the claim file in a repository they own and can only give.
224
+ export async function funderChallenge(baseUrl: string, login: string, fetchImpl: typeof fetch = fetch): Promise<KeyChallenge & { funder?: string }> {
225
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/keys/challenge?funder=${encodeURIComponent(login)}`);
226
+ return await res.json() as KeyChallenge & { funder?: string };
227
+ }
228
+ export async function funderMint(baseUrl: string, login: string, repo: string, fetchImpl: typeof fetch = fetch): Promise<MintedKey> {
229
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/keys/mint`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ funder: login, repo }) });
230
+ return await res.json() as MintedKey;
231
+ }
232
+ export async function keyRotate(baseUrl: string, currentKey: string, options: { graceSeconds?: number; fetchImpl?: typeof fetch } = {}): Promise<MintedKey> {
233
+ const res = await (options.fetchImpl ?? fetch)(`${baseUrl.replace(/\/$/, '')}/keys/rotate`, { method: 'POST', headers: { authorization: `Bearer ${currentKey}`, 'content-type': 'application/json' }, body: JSON.stringify(options.graceSeconds === undefined ? {} : { grace_seconds: options.graceSeconds }) });
234
+ return await res.json() as MintedKey;
235
+ }
package/src/drivers.ts ADDED
@@ -0,0 +1,134 @@
1
+ // Roadmap drivers: one normalized roadmap, several native homes. `file` is the default — ROADMAP.yml in
2
+ // git, the platform pulls it on sync. `github-milestones` is platform-pulled too, credential-free (public
3
+ // milestones). `jira` runs owner-side, where the credential is, and pushes through the SDK on a steer-scoped
4
+ // key. Each driver declares its conformance: what its tracker cannot express, so a reader knows which fields
5
+ // are the driver's own defaults rather than the project's word. The agent stays tracker-blind: it works and
6
+ // narrates ROADMAP.yml, and a driver's `reconcile` plan carries a finished item back to the native side.
7
+ import { ROADMAP_SCHEMA, type Roadmap, type RoadmapItem, type RoadmapStatus } from './roadmap.ts';
8
+
9
+ export type RoadmapSource = 'file' | 'github-milestones' | 'jira';
10
+ export const ROADMAP_SOURCES: readonly RoadmapSource[] = ['file', 'github-milestones', 'jira'];
11
+
12
+ export interface RoadmapConfig {
13
+ source: RoadmapSource;
14
+ path: string;
15
+ github?: { repo?: string };
16
+ jira?: { base_url?: string; project?: string; jql?: string; done_transition?: string };
17
+ }
18
+
19
+ // `.open-autonomy/config.yaml`'s `roadmap:` block. The config's shape is small and fixed: a line reader.
20
+ export function parseRoadmapConfig(yaml: string): RoadmapConfig {
21
+ const cfg: RoadmapConfig = { source: 'file', path: 'ROADMAP.yml' };
22
+ let block = '';
23
+ let sub = '';
24
+ for (const raw of yaml.split('\n')) {
25
+ const line = raw.replace(/\s+#.*$/, '').trimEnd();
26
+ if (!line.trim() || line.trim().startsWith('#')) continue;
27
+ const top = /^([a-z_]+):\s*(.*)$/.exec(line);
28
+ if (top) { block = top[2] === '' ? top[1] : ''; sub = ''; continue; }
29
+ if (block !== 'roadmap') continue;
30
+ const l2 = /^ ([a-z_]+):\s*(.*)$/.exec(line);
31
+ if (l2) {
32
+ sub = l2[2] === '' ? l2[1] : '';
33
+ const v = l2[2].trim().replace(/^["']|["']$/g, '');
34
+ if (l2[1] === 'source' && (ROADMAP_SOURCES as readonly string[]).includes(v)) cfg.source = v as RoadmapSource;
35
+ if (l2[1] === 'path' && v) cfg.path = v;
36
+ continue;
37
+ }
38
+ const l3 = /^ ([a-z_]+):\s*(.+)$/.exec(line);
39
+ if (l3 && (sub === 'github' || sub === 'jira')) {
40
+ const v = l3[2].trim().replace(/^["']|["']$/g, '');
41
+ const target: Record<string, string> = (cfg[sub] ??= {});
42
+ target[l3[1]] = v;
43
+ }
44
+ }
45
+ return cfg;
46
+ }
47
+
48
+ export interface DriverConformance { source: RoadmapSource; cannot: string[] }
49
+
50
+ export const CONFORMANCE: Record<RoadmapSource, string[]> = {
51
+ file: [],
52
+ 'github-milestones': ['priority (a milestone has none; every item is medium)', 'proposed (a milestone is open or closed; open is planned)', 'acceptance lines are the description\'s bullet lines, or its paragraphs'],
53
+ jira: ['phase (an epic has none; the epic\'s rank order is the phase)', 'proposed and active map from the status category: to-do is planned, in-progress is active, done is done'],
54
+ };
55
+
56
+ // ---- GitHub milestones -----------------------------------------------------------------------------------
57
+ export interface Milestone { number: number; title: string; description?: string | null; state: 'open' | 'closed'; due_on?: string | null; created_at?: string }
58
+
59
+ export const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'item';
60
+
61
+ // Milestones as the roadmap: due date order is the phase (undated last, by creation), a closed milestone is
62
+ // done, an open one planned; the description's bullets (else its paragraphs) are the acceptance lines.
63
+ const orderedMilestones = (milestones: Milestone[]): Milestone[] => [...milestones].sort((a, b) => (a.due_on ? Date.parse(a.due_on) : Infinity) - (b.due_on ? Date.parse(b.due_on) : Infinity) || (Date.parse(a.created_at ?? '') || 0) - (Date.parse(b.created_at ?? '') || 0) || a.number - b.number);
64
+ export function fromMilestones(milestones: Milestone[]): Roadmap {
65
+ const ordered = orderedMilestones(milestones);
66
+ const seen = new Set<string>();
67
+ let phase = 0;
68
+ let lastDue: string | null | undefined;
69
+ const items: RoadmapItem[] = ordered.map((m) => {
70
+ if (m.due_on !== lastDue || phase === 0) { phase += 1; lastDue = m.due_on; }
71
+ let id = slug(m.title);
72
+ while (seen.has(id)) id = `${id}-${m.number}`;
73
+ seen.add(id);
74
+ return { id, title: m.title, status: m.state === 'closed' ? 'done' : 'planned', phase: String(phase), priority: 'medium', acceptance: acceptanceOf(m.description ?? '') };
75
+ });
76
+ return { schema: ROADMAP_SCHEMA, items };
77
+ }
78
+ function acceptanceOf(text: string): string[] {
79
+ const bullets = text.split('\n').map((l) => /^\s*[-*]\s+(.+?)\s*$/.exec(l)?.[1]).filter((l): l is string => !!l);
80
+ if (bullets.length) return bullets;
81
+ return text.trim() ? text.trim().split(/\n{2,}/).map((p) => p.replace(/\s+/g, ' ').trim()).filter(Boolean) : [];
82
+ }
83
+ // What the native side must change to match the roadmap: an item done in the roadmap closes its milestone.
84
+ export function milestoneChanges(roadmap: Roadmap, milestones: Milestone[]): Array<{ number: number; title: string; state: 'open' | 'closed' }> {
85
+ const ordered = orderedMilestones(milestones);
86
+ const byId = new Map(fromMilestones(milestones).items.map((it, i) => [it.id, ordered[i]] as const));
87
+ const out: Array<{ number: number; title: string; state: 'open' | 'closed' }> = [];
88
+ for (const it of roadmap.items) {
89
+ const m = byId.get(it.id);
90
+ if (!m) continue;
91
+ const want: 'open' | 'closed' = it.status === 'done' ? 'closed' : 'open';
92
+ if (m.state !== want) out.push({ number: m.number, title: m.title, state: want });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // ---- Jira epics -------------------------------------------------------------------------------------------
98
+ export interface JiraEpic { key: string; summary: string; description?: string | null; statusCategory: 'new' | 'indeterminate' | 'done' | (string & {}); rank?: number; priority?: string | null }
99
+
100
+ // Epics as the roadmap: the epic key is the item id, rank order the phase, the status category the status.
101
+ export function fromJira(epics: JiraEpic[]): Roadmap {
102
+ const ordered = [...epics].sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER) || a.key.localeCompare(b.key));
103
+ const status = (c: string): RoadmapStatus => (c === 'done' ? 'done' : c === 'indeterminate' ? 'active' : 'planned');
104
+ return { schema: ROADMAP_SCHEMA, items: ordered.map((e, i) => ({ id: e.key, title: e.summary, status: status(e.statusCategory), phase: String(i + 1), priority: (e.priority ?? 'medium').toLowerCase(), acceptance: acceptanceOf(e.description ?? '') })) };
105
+ }
106
+ // An item done in the roadmap transitions its epic; one active starts it.
107
+ export function jiraChanges(roadmap: Roadmap, epics: JiraEpic[]): Array<{ key: string; to: 'done' | 'active' }> {
108
+ const byKey = new Map(epics.map((e) => [e.key, e]));
109
+ const out: Array<{ key: string; to: 'done' | 'active' }> = [];
110
+ for (const it of roadmap.items) {
111
+ const e = byKey.get(it.id);
112
+ if (!e) continue;
113
+ if (it.status === 'done' && e.statusCategory !== 'done') out.push({ key: e.key, to: 'done' });
114
+ else if (it.status === 'active' && e.statusCategory === 'new') out.push({ key: e.key, to: 'active' });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // ---- the normalized roadmap's own diff ---------------------------------------------------------------------
120
+ export interface RoadmapChange { id: string; kind: 'added' | 'removed' | 'status' | 'edited'; from?: string; to?: string }
121
+ export function diffRoadmaps(before: Roadmap | undefined, after: Roadmap): RoadmapChange[] {
122
+ const prev = new Map((before?.items ?? []).map((i) => [i.id, i]));
123
+ const next = new Map(after.items.map((i) => [i.id, i]));
124
+ const out: RoadmapChange[] = [];
125
+ for (const [id, it] of next) {
126
+ const p = prev.get(id);
127
+ if (!p) { out.push({ id, kind: 'added', to: it.status }); continue; }
128
+ if (p.status !== it.status) out.push({ id, kind: 'status', from: p.status, to: it.status });
129
+ else if (p.title !== it.title || p.phase !== it.phase || p.priority !== it.priority || p.acceptance.join('\n') !== it.acceptance.join('\n')) out.push({ id, kind: 'edited' });
130
+ }
131
+ for (const id of prev.keys()) if (!next.has(id)) out.push({ id, kind: 'removed' });
132
+ return out;
133
+ }
134
+ export const sameRoadmap = (a: Roadmap | undefined, b: Roadmap): boolean => !!a && diffRoadmaps(a, b).length === 0 && a.schema === b.schema;
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './roadmap.ts';
2
+ export * from './client.ts';
3
+ export * from './drivers.ts';
4
+ export * from './rails.ts';
package/src/rails.ts ADDED
@@ -0,0 +1,49 @@
1
+ // The rails a project's agent may spend through, as its owner bounds them in `.open-autonomy/config.yaml`:
2
+ //
3
+ // rails:
4
+ // card: # a single-use virtual card minted against the balance (Stripe Issuing)
5
+ // max_usd_cents: 2500 # the most one card may be minted for (0, the default: the rail is off)
6
+ // categories: [computer_software_stores, ...] # Stripe merchant categories a card may pay at
7
+ // partner: # a partner service settling a metered charge against the balance
8
+ // max_usd_cents: 500 # the most one charge may be (0, the default: the rail is off)
9
+ // partners: [...] # the partner ids allowed to settle
10
+ //
11
+ // The model rail needs no bounds beyond the balance and the key's models. Every rail leaves a record on the
12
+ // audit trail naming itself.
13
+ export interface RailsConfig {
14
+ card: { max_usd_cents: number; categories: string[] };
15
+ partner: { max_usd_cents: number; partners: string[] };
16
+ }
17
+ export const RAILS_OFF: RailsConfig = { card: { max_usd_cents: 0, categories: [] }, partner: { max_usd_cents: 0, partners: [] } };
18
+
19
+ export function parseRailsConfig(yaml: string): RailsConfig {
20
+ const cfg: RailsConfig = { card: { max_usd_cents: 0, categories: [] }, partner: { max_usd_cents: 0, partners: [] } };
21
+ let block = '';
22
+ let rail: 'card' | 'partner' | '' = '';
23
+ let list: 'categories' | 'partners' | '' = '';
24
+ for (const raw of yaml.split('\n')) {
25
+ const line = raw.replace(/\s+#.*$/, '').trimEnd();
26
+ if (!line.trim() || line.trim().startsWith('#')) continue;
27
+ const top = /^([a-z_]+):\s*(.*)$/.exec(line);
28
+ if (top) { block = top[2] === '' ? top[1] : ''; rail = ''; list = ''; continue; }
29
+ if (block !== 'rails') continue;
30
+ const l2 = /^ ([a-z_]+):\s*$/.exec(line);
31
+ if (l2) { rail = l2[1] === 'card' || l2[1] === 'partner' ? l2[1] : ''; list = ''; continue; }
32
+ if (!rail) continue;
33
+ const l3 = /^ ([a-z_]+):\s*(.*)$/.exec(line);
34
+ if (l3) {
35
+ const [, key, value] = l3;
36
+ list = '';
37
+ if (key === 'max_usd_cents') cfg[rail].max_usd_cents = Math.max(0, Math.floor(Number(value) || 0));
38
+ else if ((key === 'categories' && rail === 'card') || (key === 'partners' && rail === 'partner')) {
39
+ const inline = /^\[(.*)\]$/.exec(value.trim());
40
+ if (inline) (cfg[rail] as Record<string, unknown>)[key] = inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
41
+ else list = key;
42
+ }
43
+ continue;
44
+ }
45
+ const item = /^ -\s+(.+)$/.exec(line);
46
+ if (item && list) ((cfg[rail] as Record<string, unknown>)[list] as string[]).push(item[1].trim().replace(/^["']|["']$/g, ''));
47
+ }
48
+ return cfg;
49
+ }
package/src/roadmap.ts ADDED
@@ -0,0 +1,141 @@
1
+ // The roadmap model and its codec. ROADMAP.yml is the roadmap's home in git and the only place it is
2
+ // written; this module reads it into a typed shape and writes it back byte for byte. The codec keeps
3
+ // every source line, so parse → serialize is the identity and an edit rewrites only the line it touches.
4
+ // Adapters (a tracker that mirrors the roadmap, a page that renders it) are what the shape is for.
5
+ //
6
+ // The file's shape, as every kit writes it:
7
+ //
8
+ // # comments and blank lines, kept verbatim
9
+ // schema: open-autonomy.roadmap.v3
10
+ // items:
11
+ // - id: <id>
12
+ // phase: <n> (optional)
13
+ // priority: <word> (optional)
14
+ // status: proposed | planned | active | done
15
+ // title: <text>
16
+ // acceptance:
17
+ // - <line>
18
+
19
+ export const ROADMAP_SCHEMA = 'open-autonomy.roadmap.v3';
20
+ export type RoadmapStatus = 'proposed' | 'planned' | 'active' | 'done';
21
+ export const ROADMAP_STATUSES: readonly RoadmapStatus[] = ['proposed', 'planned', 'active', 'done'];
22
+
23
+ export interface RoadmapItem {
24
+ id: string;
25
+ title: string;
26
+ status: RoadmapStatus;
27
+ phase?: string;
28
+ priority?: string;
29
+ acceptance: string[];
30
+ }
31
+
32
+ export interface Roadmap {
33
+ schema: string;
34
+ items: RoadmapItem[];
35
+ }
36
+
37
+ // The parsed document: the model plus the source it came from, so it can be written back unchanged.
38
+ export interface RoadmapDocument extends Roadmap {
39
+ lines: string[];
40
+ // For each item, the index of its `- id:` line and of each scalar field line it carries.
41
+ spans: Array<{ id: string; start: number; end: number; fields: Record<string, number> }>;
42
+ }
43
+
44
+ function unquote(s: string): string {
45
+ const t = s.trim();
46
+ const m = /^(['"])(.*)\1$/.exec(t);
47
+ return m ? m[2].replace(/\\"/g, '"').replace(/''/g, "'") : t;
48
+ }
49
+
50
+ function quoteIfNeeded(s: string): string {
51
+ return /^[\w][^:#]*$/.test(s) && !/\s$/.test(s) ? s : JSON.stringify(s);
52
+ }
53
+
54
+ export function parseRoadmap(text: string): RoadmapDocument {
55
+ const lines = text.split('\n');
56
+ const doc: RoadmapDocument = { schema: '', items: [], lines, spans: [] };
57
+ let cur: { item: RoadmapItem; span: RoadmapDocument['spans'][number]; inAcceptance: boolean } | null = null;
58
+ const close = (end: number) => { if (cur) { cur.span.end = end; doc.items.push(cur.item); doc.spans.push(cur.span); cur = null; } };
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i];
61
+ const schema = /^schema:\s*(.+?)\s*$/.exec(line);
62
+ if (schema && !cur) { doc.schema = unquote(schema[1]); continue; }
63
+ const idm = /^(\s*)-\s+id:\s*(.+?)\s*$/.exec(line);
64
+ if (idm) {
65
+ close(i);
66
+ cur = { item: { id: unquote(idm[2]), title: '', status: 'planned', acceptance: [] }, span: { id: unquote(idm[2]), start: i, end: lines.length, fields: { id: i } }, inAcceptance: false };
67
+ continue;
68
+ }
69
+ if (!cur) continue;
70
+ const field = /^\s+(phase|priority|status|title):\s*(.*?)\s*$/.exec(line);
71
+ if (field) {
72
+ cur.inAcceptance = false;
73
+ const [, key, raw] = field;
74
+ const val = unquote(raw);
75
+ cur.span.fields[key] = i;
76
+ if (key === 'phase') cur.item.phase = val;
77
+ else if (key === 'priority') cur.item.priority = val;
78
+ else if (key === 'status') cur.item.status = (ROADMAP_STATUSES as readonly string[]).includes(val) ? val as RoadmapStatus : 'planned';
79
+ else cur.item.title = val;
80
+ continue;
81
+ }
82
+ if (/^\s+acceptance:\s*$/.test(line)) { cur.inAcceptance = true; cur.span.fields.acceptance = i; continue; }
83
+ const bullet = /^\s+-\s+(.+?)\s*$/.exec(line);
84
+ if (bullet && cur.inAcceptance) { cur.item.acceptance.push(unquote(bullet[1])); continue; }
85
+ if (/^\S/.test(line)) close(i); // a top-level key ends the items block
86
+ }
87
+ close(lines.length);
88
+ return doc;
89
+ }
90
+
91
+ // The source, unchanged: parse(text) → serialize is the identity.
92
+ export function serializeRoadmap(doc: RoadmapDocument): string {
93
+ return doc.lines.join('\n');
94
+ }
95
+
96
+ // A new document with one item's status changed; only that line differs from the source.
97
+ export function withStatus(doc: RoadmapDocument, itemId: string, status: RoadmapStatus): RoadmapDocument {
98
+ const span = doc.spans.find((s) => s.id === itemId);
99
+ if (!span) throw new Error(`roadmap: no item ${itemId}`);
100
+ const lines = [...doc.lines];
101
+ const at = span.fields.status;
102
+ if (at !== undefined) lines[at] = lines[at].replace(/^(\s+status:\s*).*$/, `$1${status}`);
103
+ else {
104
+ const indent = (lines[span.start].match(/^\s*/)?.[0].length ?? 0) + 2;
105
+ lines.splice(span.start + 1, 0, `${' '.repeat(indent)}status: ${status}`);
106
+ }
107
+ return parseRoadmap(lines.join('\n'));
108
+ }
109
+
110
+ // A fresh roadmap file from a model (what a kit writes at create time).
111
+ export function renderRoadmap(roadmap: Roadmap, header = ''): string {
112
+ const out: string[] = [];
113
+ if (header) out.push(...header.trimEnd().split('\n').map((l) => (l.startsWith('#') || !l ? l : `# ${l}`)));
114
+ out.push(`schema: ${roadmap.schema || ROADMAP_SCHEMA}`, 'items:');
115
+ for (const it of roadmap.items) {
116
+ out.push(` - id: ${it.id}`);
117
+ if (it.phase !== undefined) out.push(` phase: ${it.phase}`);
118
+ if (it.priority !== undefined) out.push(` priority: ${it.priority}`);
119
+ out.push(` status: ${it.status}`, ` title: ${quoteIfNeeded(it.title)}`);
120
+ if (it.acceptance.length) { out.push(' acceptance:'); for (const a of it.acceptance) out.push(` - ${quoteIfNeeded(a)}`); }
121
+ }
122
+ return `${out.join('\n')}\n`;
123
+ }
124
+
125
+ // Where an item stands, for a renderer: the status as written, anything unrecognized queued.
126
+ export type RoadmapState = 'proposed' | 'queued' | 'active' | 'done';
127
+ export function itemState(item: Pick<RoadmapItem, 'status'>): RoadmapState {
128
+ if (item.status === 'proposed' || item.status === 'active' || item.status === 'done') return item.status;
129
+ return 'queued';
130
+ }
131
+
132
+ export function phaseNumber(item: Pick<RoadmapItem, 'phase'>): number {
133
+ const n = parseInt(item.phase ?? '', 10);
134
+ return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER : n;
135
+ }
136
+
137
+ // The item the agent works next: the first active, else the first planned, in phase order.
138
+ export function nextItem(roadmap: Roadmap): RoadmapItem | undefined {
139
+ const ordered = [...roadmap.items].sort((a, b) => phaseNumber(a) - phaseNumber(b));
140
+ return ordered.find((i) => i.status === 'active') ?? ordered.find((i) => i.status === 'planned');
141
+ }