@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.
Files changed (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. 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
+ }