@opsee/cli 0.11.9
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 +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracker Adapter seam (see ../../CONTEXT.md).
|
|
3
|
+
*
|
|
4
|
+
* The piece that lets the Foreman read and write one kind of Tracker: list the Ready Issues of a
|
|
5
|
+
* Run, read one with its comments, post a Completion Report, keep its Status Label current, file a
|
|
6
|
+
* Defect, and append to the Run Record. The v1 implementation is Opsee over Connect-RPC
|
|
7
|
+
* (opsee-tracker-adapter.ts); the Foreman core only ever sees this interface.
|
|
8
|
+
*
|
|
9
|
+
* Readiness is the Tracker's to decide (ADR-0008): `listReadyTasks` returns what the server derives
|
|
10
|
+
* from the Initiative's slices and the dispatch label, never a client-side blocker walk.
|
|
11
|
+
*/
|
|
12
|
+
import type { Run, RunEvent, RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
|
|
13
|
+
|
|
14
|
+
/** The project label that makes a Task dispatchable; matched by name because every project defines
|
|
15
|
+
* its own labels (backend model.DispatchLabelName). Defined in the MCP package so that
|
|
16
|
+
* `opsee_file_defect` labels a Defect Task exactly as the Foreman does. */
|
|
17
|
+
export { DISPATCH_LABEL } from "@opsee/mcp-server/src/utils/defect.js";
|
|
18
|
+
|
|
19
|
+
/** A board column's lifecycle state. The canonical vocabulary is the first five; a project may
|
|
20
|
+
* carry others, and a column with no state at all counts as open. `in_review` is where a Task
|
|
21
|
+
* waits for a human after its Hand-off (ADR-0003); boards rarely name that state, so
|
|
22
|
+
* `pickColumn` resolves it by column name or the status editor's `active`. */
|
|
23
|
+
export type Lifecycle = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "archived" | (string & {});
|
|
24
|
+
|
|
25
|
+
export interface TrackerColumn {
|
|
26
|
+
id: number;
|
|
27
|
+
name: string;
|
|
28
|
+
lifecycle?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The column for a lifecycle state, from a board's active columns in display order: the one
|
|
32
|
+
* carrying that state, or for `in_review` (a state no board editor writes) the column whose name
|
|
33
|
+
* says review, else the board's only `active` column. Undefined when the board has none, so the
|
|
34
|
+
* adapter can name what it does have. */
|
|
35
|
+
export function pickColumn<C extends TrackerColumn>(columns: C[], lifecycle: Lifecycle): C | undefined {
|
|
36
|
+
const exact = columns.find((c) => c.lifecycle === lifecycle);
|
|
37
|
+
if (exact || lifecycle !== "in_review") return exact;
|
|
38
|
+
// A review column must be an open one: a Done-state column named "Reviewed" would close the Task
|
|
39
|
+
// and release its successors before a human merged anything.
|
|
40
|
+
const open = columns.filter((c) => c.lifecycle !== "done" && c.lifecycle !== "archived");
|
|
41
|
+
const named = open.find((c) => /review/i.test(c.name));
|
|
42
|
+
if (named) return named;
|
|
43
|
+
const active = open.filter((c) => c.lifecycle === "active");
|
|
44
|
+
return active.length === 1 ? active[0] : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TrackerTask {
|
|
48
|
+
id: number;
|
|
49
|
+
/** Human identifier, e.g. OPS-267. */
|
|
50
|
+
identifier: string;
|
|
51
|
+
title: string;
|
|
52
|
+
/** Markdown, whatever the Tracker stores: the work contract (core/work-contract.ts) reads its
|
|
53
|
+
* headings and the Worker's prompt carries it verbatim, so an adapter converts on the way in. */
|
|
54
|
+
description: string;
|
|
55
|
+
projectId: number;
|
|
56
|
+
initiativeId?: number;
|
|
57
|
+
parentTaskId?: number;
|
|
58
|
+
column?: TrackerColumn;
|
|
59
|
+
labels: string[];
|
|
60
|
+
type?: string;
|
|
61
|
+
priority?: string;
|
|
62
|
+
/** The priority as a number that rises with urgency (Opsee's `TaskPriority.level`; the seeded
|
|
63
|
+
* scale is Low 1, Medium 2, High 3, Critical 4). The scheduler's first sort key
|
|
64
|
+
* (core/scheduler.ts); unset when the Tracker reports no priority, which sorts below every
|
|
65
|
+
* named one. */
|
|
66
|
+
priorityLevel?: number;
|
|
67
|
+
/** When the Task was created: the scheduler's second sort key, oldest first. */
|
|
68
|
+
createdAt?: Date;
|
|
69
|
+
/** The Task's page in the web app, when the Tracker can build one; the Hand-off links to it. */
|
|
70
|
+
url?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TrackerComment {
|
|
74
|
+
id: number;
|
|
75
|
+
body: string;
|
|
76
|
+
authorUserId: number;
|
|
77
|
+
createdAt?: Date;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DependencyType = "blocks" | "blocked_by" | "duplicates" | "relates_to";
|
|
81
|
+
|
|
82
|
+
export interface TrackerDependency {
|
|
83
|
+
id: number;
|
|
84
|
+
fromTaskId: number;
|
|
85
|
+
toTaskId: number;
|
|
86
|
+
type: DependencyType;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Tasks that must be Done before `taskId`: `blocks` edges into it and `blocked_by` edges out of
|
|
90
|
+
* it, from a both-direction dependency list. */
|
|
91
|
+
export function blockerIdsOf(taskId: number, dependencies: TrackerDependency[]): number[] {
|
|
92
|
+
const ids = dependencies
|
|
93
|
+
.filter((d) => (d.type === "blocks" && d.toTaskId === taskId) || (d.type === "blocked_by" && d.fromTaskId === taskId))
|
|
94
|
+
.map((d) => (d.type === "blocks" ? d.fromTaskId : d.toTaskId));
|
|
95
|
+
return [...new Set(ids)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TaskWithContext {
|
|
99
|
+
task: TrackerTask;
|
|
100
|
+
comments: TrackerComment[];
|
|
101
|
+
/** Both directions: edges where the task is `from` and edges where it is `to`. */
|
|
102
|
+
dependencies: TrackerDependency[];
|
|
103
|
+
parent?: TrackerTask;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ReadyTasks {
|
|
107
|
+
tasks: TrackerTask[];
|
|
108
|
+
/** The label the server intersected with; `dispatchLabelExists` false means the project has no
|
|
109
|
+
* such label and nothing can ever be ready. */
|
|
110
|
+
dispatchLabel: string;
|
|
111
|
+
dispatchLabelExists: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface NewTask {
|
|
115
|
+
initiativeId: number;
|
|
116
|
+
title: string;
|
|
117
|
+
description: string;
|
|
118
|
+
labels?: string[];
|
|
119
|
+
/** Tasks this one relates to (a Defect names the Task it was found on). */
|
|
120
|
+
relatesTo?: number[];
|
|
121
|
+
/** Tasks that must be Done before this one is ready. */
|
|
122
|
+
blockedBy?: number[];
|
|
123
|
+
parentTaskId?: number;
|
|
124
|
+
/** Task type and priority by name; the project's first of each when omitted. */
|
|
125
|
+
type?: string;
|
|
126
|
+
priority?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ProjectRepository {
|
|
130
|
+
id: number;
|
|
131
|
+
fullName: string;
|
|
132
|
+
url: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface PullRequestLink {
|
|
136
|
+
projectRepositoryId: number;
|
|
137
|
+
number: number;
|
|
138
|
+
url: string;
|
|
139
|
+
title: string;
|
|
140
|
+
headBranch: string;
|
|
141
|
+
headSha: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type MemoryKind = "decision" | "outcome" | "learning" | "blocker" | "context";
|
|
145
|
+
|
|
146
|
+
export interface MemoryEntry {
|
|
147
|
+
kind: MemoryKind;
|
|
148
|
+
body: string;
|
|
149
|
+
/** The Task this entry is about; a Completion Report sets it. */
|
|
150
|
+
sourceTaskId?: number;
|
|
151
|
+
/** The Hand-off (draft PR) when there is one. */
|
|
152
|
+
sourceUrl?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One memory entry as read back: what Context Assembly consumes. */
|
|
156
|
+
/** Why the Tracker refused, for the refusals a caller acts on rather than only reports. A code and
|
|
157
|
+
* not the message text: the prose is English and free to change, so a caller that branches on it
|
|
158
|
+
* (the Defect filer retrying without its task type) reads the code instead. */
|
|
159
|
+
export type TrackerErrorCode = "unknown_task_type" | "unknown_priority";
|
|
160
|
+
|
|
161
|
+
/** A refusal the Tracker itself made (an unknown Initiative, a board without the column, a
|
|
162
|
+
* missing user), as opposed to a transport failure; thrown by every adapter alike. */
|
|
163
|
+
export class TrackerError extends Error {
|
|
164
|
+
/** Set on the refusals a caller branches on; undefined on the rest. */
|
|
165
|
+
readonly code?: TrackerErrorCode;
|
|
166
|
+
constructor(message: string, code?: TrackerErrorCode) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "TrackerError";
|
|
169
|
+
this.code = code;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether a rejection is the Tracker saying the project defines no task type of that name, as
|
|
174
|
+
* opposed to anything else. The typed guard every adapter's refusal answers to, so no caller has to
|
|
175
|
+
* match on the wording of a message. */
|
|
176
|
+
export function isUnknownTaskTypeError(error: unknown): boolean {
|
|
177
|
+
return error instanceof TrackerError && error.code === "unknown_task_type";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface MemoryRecord {
|
|
181
|
+
id: number;
|
|
182
|
+
kind: MemoryKind;
|
|
183
|
+
body: string;
|
|
184
|
+
sourceTaskId?: number;
|
|
185
|
+
sourceUrl?: string;
|
|
186
|
+
/** Agent-authored entries are re-injected as data, never as instructions (story 46). */
|
|
187
|
+
isAgent: boolean;
|
|
188
|
+
/** Written by the Tracker itself (a Task moved to Done, an edge changed): bookkeeping, not a
|
|
189
|
+
* Completion Report, so Context Assembly leaves it out. */
|
|
190
|
+
isSystem: boolean;
|
|
191
|
+
createdAt?: Date;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface MemoryQuery {
|
|
195
|
+
kinds?: MemoryKind[];
|
|
196
|
+
/** Only entries whose source is this Task. */
|
|
197
|
+
sourceTaskId?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface RunRecordQuery {
|
|
201
|
+
/** Only events with an id greater than this: incremental reads. */
|
|
202
|
+
afterId?: number;
|
|
203
|
+
taskId?: number;
|
|
204
|
+
kinds?: string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface RunRecord {
|
|
208
|
+
/** Absent until the first event is appended. */
|
|
209
|
+
run?: Run;
|
|
210
|
+
events: RunEvent[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One edge of an Initiative's task graph, as the server reports it. */
|
|
214
|
+
export interface TrackerEdge {
|
|
215
|
+
fromTaskId: number;
|
|
216
|
+
toTaskId: number;
|
|
217
|
+
type: DependencyType;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One layer of the server's parallel execution batches: the Tasks that can run at once. */
|
|
221
|
+
export interface TrackerSlice {
|
|
222
|
+
layer: number;
|
|
223
|
+
taskIds: number[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A pull request rolled up across an Initiative's Tasks: a Hand-off, or one a person opened. */
|
|
227
|
+
export interface TrackerPullRequest {
|
|
228
|
+
taskId: number;
|
|
229
|
+
number: number;
|
|
230
|
+
title: string;
|
|
231
|
+
url: string;
|
|
232
|
+
/** `open`, `merged`, `closed`... as the code host reports it. */
|
|
233
|
+
state: string;
|
|
234
|
+
/** `owner/repo`, when the link carries its repository. */
|
|
235
|
+
repository?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What a planning session starts from (story 10): the Initiative with its core idea, its whole
|
|
239
|
+
* task tree with statuses and edges, the memory log newest first (the Completion Reports among
|
|
240
|
+
* it), and the pull requests linked to its Tasks. Mirrors the MCP's `opsee_get_initiative_context`
|
|
241
|
+
* (backend GetInitiativeContext), read through the Tracker Adapter so `foreman plan` sees the same
|
|
242
|
+
* Initiative a planning skill would. */
|
|
243
|
+
export interface InitiativeContext {
|
|
244
|
+
initiative: {
|
|
245
|
+
id: number;
|
|
246
|
+
title: string;
|
|
247
|
+
summary?: string;
|
|
248
|
+
status: string;
|
|
249
|
+
/** Markdown, converted on the way in like a Task description. */
|
|
250
|
+
coreIdea: string;
|
|
251
|
+
projectId: number;
|
|
252
|
+
};
|
|
253
|
+
/** Every Task of the Initiative, with its column, whatever its state. */
|
|
254
|
+
tasks: TrackerTask[];
|
|
255
|
+
edges: TrackerEdge[];
|
|
256
|
+
slices: TrackerSlice[];
|
|
257
|
+
/** The whole memory log, newest first, system entries included (a reader filters). */
|
|
258
|
+
memory: MemoryRecord[];
|
|
259
|
+
pullRequests: TrackerPullRequest[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface TrackerAdapter {
|
|
263
|
+
listReadyTasks(initiativeId: number): Promise<ReadyTasks>;
|
|
264
|
+
getTask(taskId: number): Promise<TaskWithContext>;
|
|
265
|
+
addComment(taskId: number, body: string): Promise<TrackerComment>;
|
|
266
|
+
/** The Initiative's own discussion thread, the counterpart of `addComment` (story 62). Where the
|
|
267
|
+
* Run's end summary goes: the per-Task comments say what happened to each Task, and this is the
|
|
268
|
+
* one place that says what happened to the night. */
|
|
269
|
+
addInitiativeComment(initiativeId: number, body: string): Promise<TrackerComment>;
|
|
270
|
+
/** Moves the Task to the board column carrying this lifecycle state; rejects when the board has
|
|
271
|
+
* none, naming the states it does have. */
|
|
272
|
+
moveTask(taskId: number, lifecycle: Lifecycle): Promise<TrackerColumn>;
|
|
273
|
+
/** Idempotent: attaching a label the Task already carries is a no-op. */
|
|
274
|
+
attachLabel(taskId: number, label: string): Promise<void>;
|
|
275
|
+
/** Idempotent: detaching a label the Task does not carry is a no-op. */
|
|
276
|
+
detachLabel(taskId: number, label: string): Promise<void>;
|
|
277
|
+
/** Makes sure the project has an active label of this name (a Status Label the Foreman keeps
|
|
278
|
+
* on the Tasks it touches); idempotent. */
|
|
279
|
+
ensureLabel(projectId: number, label: string): Promise<void>;
|
|
280
|
+
/** Creates the Task inside the Initiative with its labels and edges. Either the whole Task exists
|
|
281
|
+
* as asked, or the call rejects and nothing is left behind (an implementation that needs several
|
|
282
|
+
* writes undoes the create when a later write fails). */
|
|
283
|
+
createTask(input: NewTask): Promise<TrackerTask>;
|
|
284
|
+
/** The repositories connected to a project; `linkPullRequest` needs one of their ids. */
|
|
285
|
+
projectRepositories(projectId: number): Promise<ProjectRepository[]>;
|
|
286
|
+
linkPullRequest(taskId: number, pr: PullRequestLink): Promise<void>;
|
|
287
|
+
addMemory(initiativeId: number, entry: MemoryEntry): Promise<{ id: number }>;
|
|
288
|
+
/** The Initiative's memory log, newest first, filtered by kind and source Task. What Context
|
|
289
|
+
* Assembly reads: a blocker's Completion Report is its `outcome` entries with that Task as source. */
|
|
290
|
+
listMemory(initiativeId: number, query?: MemoryQuery): Promise<MemoryRecord[]>;
|
|
291
|
+
/** Appends in order, atomically: every event lands or none does. */
|
|
292
|
+
appendRunEvents(initiativeId: number, events: RunEventInput[]): Promise<RunEvent[]>;
|
|
293
|
+
/** The whole matching Run Record in id order, however long; `afterId` reads incrementally. */
|
|
294
|
+
readRunRecord(initiativeId: number, query?: RunRecordQuery): Promise<RunRecord>;
|
|
295
|
+
/** The Initiative as a planning session reads it (story 10): core idea, task tree, memory, pull
|
|
296
|
+
* requests. Rejects when there is no such Initiative. */
|
|
297
|
+
getInitiativeContext(initiativeId: number): Promise<InitiativeContext>;
|
|
298
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Triage turn (see ../../CONTEXT.md, spec story 23) produces: a drafted Verification section
|
|
3
|
+
* for a Task that has none.
|
|
4
|
+
*
|
|
5
|
+
* A Triage turn is an ordinary unattended turn, so the vendor constrains its final message to the
|
|
6
|
+
* Completion Report schema like any other; both adapters hard-wire that schema, and a second one
|
|
7
|
+
* would change the Worker Adapter's terminal event, which the seam does not allow. The draft
|
|
8
|
+
* therefore travels in the report's `summary` (the prompt in core/triage.ts asks for exactly that,
|
|
9
|
+
* outcome `done`, nothing else filled in), and this module is the projection from report to draft.
|
|
10
|
+
*/
|
|
11
|
+
import type { CompletionReport } from "./completion-report.js";
|
|
12
|
+
|
|
13
|
+
export interface TriageDraft {
|
|
14
|
+
/** The Verification section body, markdown, without its heading. */
|
|
15
|
+
verification: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ParsedTriageDraft = { ok: true; draft: TriageDraft } | { ok: false; reason: string };
|
|
19
|
+
|
|
20
|
+
/** A heading line naming Verification, at any level, with or without a colon. */
|
|
21
|
+
const VERIFICATION_HEADING = /^ {0,3}#{1,6}\s+verification\s*:?\s*#*\s*$/i;
|
|
22
|
+
|
|
23
|
+
function firstSentence(text: string): string {
|
|
24
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
25
|
+
const end = trimmed.search(/[.!?](\s|$)/);
|
|
26
|
+
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The draft in a Triage turn's report: its summary, less a heading the Worker added anyway. A
|
|
30
|
+
* report that is not `done`, or says nothing, is no draft, with the Worker's own reason. */
|
|
31
|
+
export function triageDraftFrom(report: CompletionReport): ParsedTriageDraft {
|
|
32
|
+
if (report.outcome !== "done") {
|
|
33
|
+
return { ok: false, reason: `the Worker reported ${report.outcome}: ${firstSentence(report.blockers[0] ?? report.summary)}` };
|
|
34
|
+
}
|
|
35
|
+
const lines = report.summary.trim().split("\n");
|
|
36
|
+
if (lines.length > 0 && VERIFICATION_HEADING.test(lines[0])) lines.shift();
|
|
37
|
+
const verification = lines.join("\n").trim();
|
|
38
|
+
if (!verification) return { ok: false, reason: "the Worker reported done with an empty summary" };
|
|
39
|
+
return { ok: true, draft: { verification } };
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The coding-agent products whose CLIs can be Workers in v1 (ADR-0001). */
|
|
2
|
+
export const VENDORS = ["claude", "codex"] as const;
|
|
3
|
+
export type Vendor = (typeof VENDORS)[number];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The environment variable each vendor's CLI honours as its config directory. The Worker Adapter
|
|
7
|
+
* sets it to a subscription Account's `configDir` so a Worker runs under that login and no other
|
|
8
|
+
* (ADR-0013); the Foreman itself never reads what is inside the directory.
|
|
9
|
+
*
|
|
10
|
+
* - Claude Code: `CLAUDE_CONFIG_DIR`, default `~/.claude`.
|
|
11
|
+
* - Codex: `CODEX_HOME`, default `~/.codex`. It "sets the root for Codex state, including config,
|
|
12
|
+
* auth, logs, sessions, skills", and "if you set it, the directory must already exist"
|
|
13
|
+
* (https://learn.chatgpt.com/docs/config-file/environment-variables, where
|
|
14
|
+
* developers.openai.com/codex redirects).
|
|
15
|
+
*/
|
|
16
|
+
export const VENDOR_CONFIG_DIR_ENV: Readonly<Record<Vendor, string>> = {
|
|
17
|
+
claude: "CLAUDE_CONFIG_DIR",
|
|
18
|
+
codex: "CODEX_HOME",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function isVendor(value: string): value is Vendor {
|
|
22
|
+
return (VENDORS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Verdict (see ../../CONTEXT.md: Verdict, Defect; spec story 39): the Verifier's structured
|
|
3
|
+
* result, passed or a list of Defects, each with the steps that led to it, what was expected, what
|
|
4
|
+
* was observed and a screenshot. It is the output contract of the Verifier's turn
|
|
5
|
+
* (worker-adapter.ts `OutputContract`), enforced the way the Completion Report is: one JSON schema
|
|
6
|
+
* handed to the vendor, one validator that judges what came back, and a turn that ends with
|
|
7
|
+
* anything else is `invalid_report`, which the Verifier records as a failed round, never a pass.
|
|
8
|
+
*
|
|
9
|
+
* Every key is required and no field is nullable, so the Codex strict projection is the schema
|
|
10
|
+
* itself and nothing needs folding; a passed Verdict has an empty `defects` list.
|
|
11
|
+
*/
|
|
12
|
+
import type { OutputContract } from "./worker-adapter.js";
|
|
13
|
+
|
|
14
|
+
export interface Defect {
|
|
15
|
+
/** One line naming the failure; the Defect's title when it is filed as a Task (OPS-273). */
|
|
16
|
+
title: string;
|
|
17
|
+
/** What the Verifier did, step by step, to reach it. */
|
|
18
|
+
steps: string;
|
|
19
|
+
expected: string;
|
|
20
|
+
observed: string;
|
|
21
|
+
/** Path of the screenshot the Verifier took, relative to its screenshot directory (or absolute);
|
|
22
|
+
* empty when it could not take one, which the comment then says. */
|
|
23
|
+
screenshot: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Verdict {
|
|
27
|
+
passed: boolean;
|
|
28
|
+
/** Empty when passed; at least one when not. */
|
|
29
|
+
defects: Defect[];
|
|
30
|
+
/** One paragraph: what was exercised and what was seen, for the human reading the pull request. */
|
|
31
|
+
summary: string;
|
|
32
|
+
/** Set only by the Foreman, on a Verdict it synthesized for a round that failed before the
|
|
33
|
+
* Verifier observed anything (`failureVerdict`, core/verifier.ts): its one stand-in Defect says
|
|
34
|
+
* the Foreman could not verify, which is not something a Worker can be asked to fix, so the
|
|
35
|
+
* Defect filer never files it. Structural on purpose (OPS-273 review): `parseVerdict` builds a
|
|
36
|
+
* Verdict field by field from the agent's JSON and never sets this, so a Verifier can neither
|
|
37
|
+
* make a real Defect look synthesized nor a synthesized one look real, whatever it titles them. */
|
|
38
|
+
roundFailed?: true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const VERDICT_JSON_SCHEMA = {
|
|
42
|
+
type: "object",
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
required: ["passed", "defects", "summary"],
|
|
45
|
+
properties: {
|
|
46
|
+
passed: { type: "boolean" },
|
|
47
|
+
defects: {
|
|
48
|
+
type: "array",
|
|
49
|
+
items: {
|
|
50
|
+
type: "object",
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
required: ["title", "steps", "expected", "observed", "screenshot"],
|
|
53
|
+
properties: {
|
|
54
|
+
title: { type: "string" },
|
|
55
|
+
steps: { type: "string" },
|
|
56
|
+
expected: { type: "string" },
|
|
57
|
+
observed: { type: "string" },
|
|
58
|
+
screenshot: { type: "string" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
summary: { type: "string" },
|
|
63
|
+
},
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
export type ParsedVerdict = { ok: true; verdict: Verdict } | { ok: false; errors: string[] };
|
|
67
|
+
|
|
68
|
+
const DEFECT_FIELDS = ["title", "steps", "expected", "observed", "screenshot"] as const;
|
|
69
|
+
|
|
70
|
+
/** Validates a candidate Verdict. A string is parsed as JSON first, as the Completion Report is.
|
|
71
|
+
* Every defect is reported, since the list goes into the failure a human reads; a Verdict whose
|
|
72
|
+
* `passed` and `defects` disagree is malformed, the rule the backend enforces on the event too. */
|
|
73
|
+
export function parseVerdict(candidate: unknown): ParsedVerdict {
|
|
74
|
+
let value = candidate;
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(value);
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false, errors: ["verdict is not JSON"] };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { ok: false, errors: ["verdict must be an object"] };
|
|
83
|
+
const record = value as Record<string, unknown>;
|
|
84
|
+
const errors: string[] = [];
|
|
85
|
+
if (typeof record.passed !== "boolean") errors.push("passed must be a boolean");
|
|
86
|
+
if (typeof record.summary !== "string") errors.push("summary must be a string");
|
|
87
|
+
const defects: Defect[] = [];
|
|
88
|
+
if (!Array.isArray(record.defects)) {
|
|
89
|
+
errors.push("defects must be an array");
|
|
90
|
+
} else {
|
|
91
|
+
record.defects.forEach((raw, i) => {
|
|
92
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
93
|
+
errors.push(`defects[${i}] must be an object`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const d = raw as Record<string, unknown>;
|
|
97
|
+
const defect: Partial<Defect> = {};
|
|
98
|
+
for (const field of DEFECT_FIELDS) {
|
|
99
|
+
if (typeof d[field] !== "string") errors.push(`defects[${i}].${field} must be a string`);
|
|
100
|
+
else defect[field] = d[field] as string;
|
|
101
|
+
}
|
|
102
|
+
if (typeof d.title === "string" && d.title.trim() === "") errors.push(`defects[${i}].title must not be empty`);
|
|
103
|
+
defects.push(defect as Defect);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (record.passed === true && defects.length > 0) errors.push("a passed verdict lists no defects");
|
|
107
|
+
if (record.passed === false && Array.isArray(record.defects) && defects.length === 0) errors.push("a failed verdict lists at least one defect");
|
|
108
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
109
|
+
return { ok: true, verdict: { passed: record.passed as boolean, defects, summary: record.summary as string } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The Verdict as an output contract: what the Verifier's turn runs under. */
|
|
113
|
+
export const VERDICT_CONTRACT: OutputContract<Verdict> = {
|
|
114
|
+
name: "Verdict",
|
|
115
|
+
jsonSchema: VERDICT_JSON_SCHEMA as unknown as Record<string, unknown>,
|
|
116
|
+
parse(candidate) {
|
|
117
|
+
const parsed = parseVerdict(candidate);
|
|
118
|
+
return parsed.ok ? { ok: true, value: parsed.verdict } : parsed;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Adapter seam (see ../../CONTEXT.md).
|
|
3
|
+
*
|
|
4
|
+
* The vendor-specific piece that knows how to launch, observe, resume, and stop a Worker for one
|
|
5
|
+
* coding-agent product. The Foreman only speaks to Worker Adapters, never to a vendor directly, so
|
|
6
|
+
* nothing here names a vendor: the Account carries that, and the adapter implementation is chosen
|
|
7
|
+
* per Account vendor. Tests substitute `FakeWorkerAdapter`, which replays scripted event streams;
|
|
8
|
+
* the real adapters are contract-tested against recorded stream fixtures with the process seam
|
|
9
|
+
* replaced by a replayer (spec, "Testing Decisions").
|
|
10
|
+
*/
|
|
11
|
+
import type { Account } from "./account.js";
|
|
12
|
+
import type { CompletionReport } from "./completion-report.js";
|
|
13
|
+
|
|
14
|
+
/** One unattended turn of a Worker. The Foreman pins the working directory to the Workspace and
|
|
15
|
+
* says which Account the Worker runs under; the adapter turns that into a vendor launch. */
|
|
16
|
+
export interface TurnRequest {
|
|
17
|
+
account: Account;
|
|
18
|
+
/** Absolute path the Worker runs in. Always a Workspace once the Foreman core lands. */
|
|
19
|
+
cwd: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
/** Cap on agentic turns within this Worker turn, where the vendor supports one (story 33). */
|
|
22
|
+
maxTurns?: number;
|
|
23
|
+
/** Silence longer than this ends the turn as `stalled` (story 33). Unset means no stall timer. */
|
|
24
|
+
stallTimeoutMs?: number;
|
|
25
|
+
/** Extra environment for the Worker process, applied after the adapter's own variables. Never a
|
|
26
|
+
* credential: the Account decides identity and the adapter sets that itself. */
|
|
27
|
+
env?: Readonly<Record<string, string>>;
|
|
28
|
+
/** What the turn must end with. The Completion Report (`COMPLETION_REPORT_CONTRACT`) when unset,
|
|
29
|
+
* which is every implementing turn; the Verifier's turn (core/verifier.ts) hands in the Verdict
|
|
30
|
+
* contract instead. The adapter gives the vendor the contract's JSON schema and validates what
|
|
31
|
+
* comes back with its parser, so a turn that ends with anything else is `invalid_report`
|
|
32
|
+
* whichever contract it ran under. */
|
|
33
|
+
contract?: OutputContract;
|
|
34
|
+
/** MCP servers the turn may use on top of the vendor's own configuration: the Verifier's
|
|
35
|
+
* Playwright MCP. Each adapter renders them the way its vendor takes them and allows their tools;
|
|
36
|
+
* a turn without any runs with the vendor's defaults. */
|
|
37
|
+
mcpServers?: Readonly<Record<string, McpServerSpec>>;
|
|
38
|
+
/** What the Worker may touch. `worker` (the default) is an implementing turn: it edits its
|
|
39
|
+
* Workspace and runs commands there. `readonly-browser` is the Verifier's turn
|
|
40
|
+
* (core/verifier.ts): the vendor's built-in tools are removed or its sandbox made read-only,
|
|
41
|
+
* nothing may be written or run, the MCP servers named here are the only tools, and the
|
|
42
|
+
* process environment loses the Foreman's own credentials as well as the vendors' identities
|
|
43
|
+
* (worker-process.ts `VERIFIER_STRIPPED_ENV`). A property of the request, not of the contract,
|
|
44
|
+
* so the adapters need not know which contract is which. */
|
|
45
|
+
sandbox?: TurnSandbox;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TurnSandbox = "worker" | "readonly-browser";
|
|
49
|
+
|
|
50
|
+
/** One stdio MCP server, as both vendors describe it: a command, its arguments, extra environment. */
|
|
51
|
+
export interface McpServerSpec {
|
|
52
|
+
command: string;
|
|
53
|
+
args: string[];
|
|
54
|
+
env?: Readonly<Record<string, string>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a turn must hand back as its final, structured message: a JSON schema for the vendor's
|
|
58
|
+
* structured-output flag and the validator that judges what came back. One object per contract
|
|
59
|
+
* (the Completion Report, the Verdict), so the schema and its parser cannot drift apart. */
|
|
60
|
+
export interface OutputContract<T = unknown> {
|
|
61
|
+
/** What the failure event calls it: "Completion Report", "Verdict". */
|
|
62
|
+
name: string;
|
|
63
|
+
jsonSchema: Record<string, unknown>;
|
|
64
|
+
parse(candidate: unknown): { ok: true; value: T } | { ok: false; errors: string[] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why a turn ended without a Completion Report. */
|
|
68
|
+
export type TurnFailureReason =
|
|
69
|
+
/** The process could not be started at all: binary missing, key variable unset, bad cwd. */
|
|
70
|
+
| "launch_failed"
|
|
71
|
+
/** The Worker finished but its final message was not valid under the turn's contract (a
|
|
72
|
+
* Completion Report, or the Verifier's Verdict). */
|
|
73
|
+
| "invalid_report"
|
|
74
|
+
/** The vendor stopped the Worker at its turn cap before it reported. */
|
|
75
|
+
| "max_turns"
|
|
76
|
+
/** The vendor refused or cut the turn on a rate limit; a `rate_limited` event preceded this. */
|
|
77
|
+
| "rate_limited"
|
|
78
|
+
/**
|
|
79
|
+
* The Account's credential is dead rather than busy: a login the vendor no longer accepts, a
|
|
80
|
+
* revoked or expired token, an API-key variable that is not set (story 33, OPS-288). Distinct
|
|
81
|
+
* from `rate_limited`, which is the same identity being told "not yet", and from `launch_failed`,
|
|
82
|
+
* which is a machine that could not start the binary at all — a dead credential is the one
|
|
83
|
+
* failure that no amount of retrying on this Account can fix, so it is what quarantines it.
|
|
84
|
+
*
|
|
85
|
+
* The adapters are deliberately stingy with it. A false positive takes a working Account out of
|
|
86
|
+
* rotation until a human re-adds it, which on an overnight run costs the whole Lane, so it is
|
|
87
|
+
* raised only for a locally-decided fact (`CredentialError`) or a narrow match against the
|
|
88
|
+
* vendor's own documented auth text, and never where a rate limit would also match.
|
|
89
|
+
*/
|
|
90
|
+
| "credential_failed"
|
|
91
|
+
/** No output for `stallTimeoutMs`; the adapter stopped the Worker. A `stalled` event preceded this. */
|
|
92
|
+
| "stalled"
|
|
93
|
+
/** `stop()` was called before the Worker reported. */
|
|
94
|
+
| "stopped"
|
|
95
|
+
/** The vendor reported an error of its own, or the process exited without a result. */
|
|
96
|
+
| "vendor_error";
|
|
97
|
+
|
|
98
|
+
export type AdapterEvent =
|
|
99
|
+
/** The Worker is running. `sessionId` is what `resume` needs for the next turn. */
|
|
100
|
+
| { type: "started"; sessionId: string }
|
|
101
|
+
/** Assistant text as it arrives. Progress for the Dashboard; never parsed for meaning. */
|
|
102
|
+
| { type: "output"; text: string }
|
|
103
|
+
/** The Worker invoked a tool; progress for the Dashboard. */
|
|
104
|
+
| { type: "tool"; name: string }
|
|
105
|
+
/** The vendor reported a rate limit. `resetAt` is ISO 8601 when the vendor said when it lifts;
|
|
106
|
+
* the scheduler marks the Account Paused until then (or a default when unknown). */
|
|
107
|
+
| { type: "rate_limited"; resetAt?: string; message: string }
|
|
108
|
+
/** No output for `stallTimeoutMs`. Always followed by `failed` with reason `stalled`. */
|
|
109
|
+
| { type: "stalled"; silentMs: number }
|
|
110
|
+
/** Terminal: the Worker ended with a valid final message under its contract. `report` is the
|
|
111
|
+
* Completion Report of an implementing turn; a turn run under another contract (the Verifier's
|
|
112
|
+
* Verdict) carries the validated object in `structured` and a stand-in report that says so
|
|
113
|
+
* (`contractReport`), so every reader of the stream sees the same shape. */
|
|
114
|
+
| { type: "completed"; sessionId: string; report: CompletionReport; structured?: unknown; costUsd?: number; numTurns?: number }
|
|
115
|
+
/** Terminal: the turn ended without a valid final message. `details` carries the validation
|
|
116
|
+
* errors for `invalid_report` and the vendor's text otherwise. */
|
|
117
|
+
| { type: "failed"; reason: TurnFailureReason; message: string; sessionId?: string; details?: string[] };
|
|
118
|
+
|
|
119
|
+
/** One launched turn. Iterate `events` to completion; exactly one terminal event is yielded and the
|
|
120
|
+
* iterator then ends. `stop()` may be called at any time and is idempotent. */
|
|
121
|
+
export interface TurnHandle {
|
|
122
|
+
events: AsyncIterable<AdapterEvent>;
|
|
123
|
+
stop(): Promise<void>;
|
|
124
|
+
/** The Worker's OS pid once it is running, for the Process Table (ADR-0009); unset for an
|
|
125
|
+
* adapter that has no process (the fake) or before the launch. */
|
|
126
|
+
readonly pid?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What `foreman attach` runs in the human's terminal: the vendor's own interactive resume of a
|
|
130
|
+
* session, in the Workspace, under the Account's identity. The adapter builds it, the command
|
|
131
|
+
* spawns it with the terminal inherited; the Foreman never drives the attended turn itself. */
|
|
132
|
+
export interface InteractiveCommand {
|
|
133
|
+
command: string;
|
|
134
|
+
args: string[];
|
|
135
|
+
cwd: string;
|
|
136
|
+
/** The Worker's environment, built from the Account alone the way an unattended turn's is. */
|
|
137
|
+
env: Record<string, string>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What `foreman plan` opens: a fresh interactive session of the vendor, in a Workspace, under the
|
|
141
|
+
* Account, whose first message is `prompt`. No session id: nothing is resumed. The command keeps
|
|
142
|
+
* `prompt` under the OS argument limit itself (a long context goes to a file the prompt points at). */
|
|
143
|
+
export interface InteractiveSessionRequest extends Pick<TurnRequest, "account" | "cwd"> {
|
|
144
|
+
prompt: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface WorkerAdapter {
|
|
148
|
+
/** Starts a new Worker session for one unattended turn. */
|
|
149
|
+
launch(request: TurnRequest): TurnHandle;
|
|
150
|
+
/** Runs a further turn in an existing session, so the Worker sees its earlier context. */
|
|
151
|
+
resume(sessionId: string, request: TurnRequest): TurnHandle;
|
|
152
|
+
/** The attended turn (story 53): how to resume `sessionId` interactively in `cwd` under the
|
|
153
|
+
* Account. Turn mode is a property of the turn, not the Worker: this is the same session the
|
|
154
|
+
* unattended turns use, so what the human says and does there is what the next unattended turn
|
|
155
|
+
* sees. */
|
|
156
|
+
interactiveCommand(sessionId: string, request: Pick<TurnRequest, "account" | "cwd">): InteractiveCommand;
|
|
157
|
+
/** An attended session from scratch (story 10, `foreman plan`): the vendor's interactive form
|
|
158
|
+
* opened on `prompt` as its first message, in `cwd`, under the Account's environment. The
|
|
159
|
+
* Foreman never drives it and it is never resumed unattended. */
|
|
160
|
+
interactiveSession(request: InteractiveSessionRequest): InteractiveCommand;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Drains a handle and returns its terminal event, collecting the rest, for callers that want the
|
|
164
|
+
* outcome rather than the live stream (the contract suite and most Foreman tests; the debug command
|
|
165
|
+
* streams so a human sees progress). */
|
|
166
|
+
export async function collectTurn(handle: TurnHandle): Promise<{
|
|
167
|
+
events: AdapterEvent[];
|
|
168
|
+
terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
|
|
169
|
+
}> {
|
|
170
|
+
const events: AdapterEvent[] = [];
|
|
171
|
+
for await (const event of handle.events) events.push(event);
|
|
172
|
+
const terminal = events[events.length - 1];
|
|
173
|
+
if (!terminal || (terminal.type !== "completed" && terminal.type !== "failed")) {
|
|
174
|
+
throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
|
|
175
|
+
}
|
|
176
|
+
return { events, terminal };
|
|
177
|
+
}
|