@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,355 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import { RunEventSchema, RunSchema, type RunEvent, type RunEventInput } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
3
+ import {
4
+ blockerIdsOf,
5
+ DISPATCH_LABEL,
6
+ pickColumn,
7
+ TrackerError,
8
+ type InitiativeContext,
9
+ type Lifecycle,
10
+ type MemoryEntry,
11
+ type MemoryQuery,
12
+ type MemoryRecord,
13
+ type NewTask,
14
+ type ProjectRepository,
15
+ type PullRequestLink,
16
+ type ReadyTasks,
17
+ type RunRecord,
18
+ type RunRecordQuery,
19
+ type TaskWithContext,
20
+ type TrackerAdapter,
21
+ type TrackerColumn,
22
+ type TrackerComment,
23
+ type TrackerDependency,
24
+ type TrackerSlice,
25
+ type TrackerTask,
26
+ } from "./tracker-adapter.js";
27
+
28
+ /**
29
+ * An in-memory Tracker for the Foreman core's tests: one project with one connected repository,
30
+ * one board with the five canonical columns plus an In Review column shaped like the live board's
31
+ * (lifecycle `active`, the status editor's default), Tasks with labels and edges, a memory log and
32
+ * a Run Record. Readiness follows the server's rule closely enough for the loop's decisions (open
33
+ * Task, dispatch label, every blocker Done, where In Review is still open); the real rule is
34
+ * exercised against the real backend in the integration tier.
35
+ */
36
+ export const FAKE_COLUMNS: TrackerColumn[] = [
37
+ { id: 1, name: "Backlog", lifecycle: "backlog" },
38
+ { id: 2, name: "To Do", lifecycle: "todo" },
39
+ { id: 3, name: "In Progress", lifecycle: "in_progress" },
40
+ { id: 6, name: "In Review", lifecycle: "active" },
41
+ { id: 4, name: "Done", lifecycle: "done" },
42
+ { id: 5, name: "Archived", lifecycle: "archived" },
43
+ ];
44
+
45
+ export const FAKE_REPOSITORY: ProjectRepository = { id: 1, fullName: "opsee/monorepo", url: "https://gitlab.example/opsee/monorepo" };
46
+
47
+ /** The description a fake Task gets when the test gives none: a complete work contract, so a test
48
+ * about the loop is not also a test about the contract check (work-contract.ts). */
49
+ export const FAKE_CONTRACT = "## Goal\n\nDo the thing.\n\n## Acceptance Criteria\n\n- [ ] The thing is done\n\n## Verification\n\nRun the tests.";
50
+
51
+ /** An Initiative the fake knows by id; a Task's `initiativeId` may name one not registered here,
52
+ * in which case `getInitiativeContext` rejects the way the server does for an unknown id. */
53
+ export interface FakeInitiativeInput {
54
+ id: number;
55
+ title: string;
56
+ summary?: string;
57
+ status?: string;
58
+ /** Markdown; the fake stores it as the adapter would hand it out, already converted. */
59
+ coreIdea?: string;
60
+ }
61
+
62
+ export interface FakeTaskInput {
63
+ id: number;
64
+ identifier?: string;
65
+ title: string;
66
+ /** Defaults to `FAKE_CONTRACT`; pass "" for a Task with no description at all. */
67
+ description?: string;
68
+ initiativeId?: number;
69
+ parentTaskId?: number;
70
+ labels?: string[];
71
+ lifecycle?: Lifecycle;
72
+ blockedBy?: number[];
73
+ /** Tasks this one relates to; `createTask` wires a `relates_to` edge per entry, as the adapter's
74
+ * RELATES_TO dependency does. */
75
+ relatesTo?: number[];
76
+ /** The project's task type by name (the fake defines no types of its own, so any name is taken
77
+ * as given); what the Defect filer sets to `Bug`. */
78
+ type?: string;
79
+ /** The scheduler's sort keys (core/scheduler.ts). `priorityLevel` rises with urgency the way
80
+ * Opsee's `TaskPriority.level` does; `createdAt` is when the Task was made. Both unset by
81
+ * default, which leaves the order the ids' — what a test that is not about ordering wants. */
82
+ priorityLevel?: number;
83
+ createdAt?: Date;
84
+ }
85
+
86
+ export class FakeTrackerAdapter implements TrackerAdapter {
87
+ readonly projectId: number;
88
+ readonly tasks = new Map<number, TrackerTask>();
89
+ readonly dependencies: TrackerDependency[] = [];
90
+ readonly comments = new Map<number, TrackerComment[]>();
91
+ /** By Initiative id, kept apart from the Task comments above: the two are different threads and a
92
+ * test that asserts one must not be able to see the other. */
93
+ readonly initiativeComments = new Map<number, TrackerComment[]>();
94
+ readonly memory: (MemoryRecord & { initiativeId: number })[] = [];
95
+ readonly events: RunEvent[] = [];
96
+ readonly labels = new Set<string>([DISPATCH_LABEL]);
97
+ readonly repositories: ProjectRepository[];
98
+ readonly pullRequests = new Map<number, PullRequestLink[]>();
99
+ readonly initiatives = new Map<number, Required<FakeInitiativeInput>>();
100
+ /** The project's task types by name, first being its default. Undefined means the fake takes any
101
+ * name, which is what a test not about task types wants. */
102
+ readonly taskTypes?: string[];
103
+ /** Every write, in order, so a test can assert the sequence the loop performed. */
104
+ readonly log: string[] = [];
105
+ private nextId = 1000;
106
+
107
+ constructor(options: { projectId?: number; tasks?: FakeTaskInput[]; repositories?: ProjectRepository[]; initiatives?: FakeInitiativeInput[]; taskTypes?: string[] } = {}) {
108
+ this.projectId = options.projectId ?? 3;
109
+ this.taskTypes = options.taskTypes;
110
+ this.repositories = options.repositories ?? [FAKE_REPOSITORY];
111
+ for (const i of options.initiatives ?? []) this.addInitiative(i);
112
+ for (const t of options.tasks ?? []) this.addTask(t);
113
+ }
114
+
115
+ addInitiative(input: FakeInitiativeInput): void {
116
+ this.initiatives.set(input.id, { summary: "", status: "active", coreIdea: "", ...input });
117
+ }
118
+
119
+ addTask(input: FakeTaskInput): TrackerTask {
120
+ const column = FAKE_COLUMNS.find((c) => c.lifecycle === (input.lifecycle ?? "todo"))!;
121
+ const task: TrackerTask = {
122
+ id: input.id,
123
+ identifier: input.identifier ?? `OPS-${input.id}`,
124
+ title: input.title,
125
+ description: input.description ?? FAKE_CONTRACT,
126
+ projectId: this.projectId,
127
+ initiativeId: input.initiativeId,
128
+ parentTaskId: input.parentTaskId,
129
+ column,
130
+ labels: [...(input.labels ?? [])],
131
+ type: input.type,
132
+ priorityLevel: input.priorityLevel,
133
+ createdAt: input.createdAt,
134
+ };
135
+ this.tasks.set(task.id, task);
136
+ for (const blocker of input.blockedBy ?? []) {
137
+ this.dependencies.push({ id: this.nextId++, fromTaskId: blocker, toTaskId: task.id, type: "blocks" });
138
+ }
139
+ for (const other of input.relatesTo ?? []) {
140
+ this.dependencies.push({ id: this.nextId++, fromTaskId: task.id, toTaskId: other, type: "relates_to" });
141
+ }
142
+ return task;
143
+ }
144
+
145
+ private task(id: number): TrackerTask {
146
+ const task = this.tasks.get(id);
147
+ if (!task) throw new Error(`Task ${id} not found`);
148
+ return task;
149
+ }
150
+
151
+ private blockersOf(taskId: number): number[] {
152
+ return blockerIdsOf(taskId, this.dependencies);
153
+ }
154
+
155
+ async listReadyTasks(initiativeId: number): Promise<ReadyTasks> {
156
+ const isDone = (t: TrackerTask) => t.column?.lifecycle === "done" || t.column?.lifecycle === "archived";
157
+ const tasks = [...this.tasks.values()].filter(
158
+ (t) =>
159
+ t.initiativeId === initiativeId &&
160
+ !isDone(t) &&
161
+ t.labels.includes(DISPATCH_LABEL) &&
162
+ // An edge to a Task the Tracker no longer has does not gate readiness, as on the server.
163
+ this.blockersOf(t.id).every((b) => !this.tasks.has(b) || isDone(this.tasks.get(b)!)),
164
+ );
165
+ return { tasks: tasks.map((t) => ({ ...t, labels: [...t.labels] })), dispatchLabel: DISPATCH_LABEL, dispatchLabelExists: true };
166
+ }
167
+
168
+ async getTask(taskId: number): Promise<TaskWithContext> {
169
+ const task = this.task(taskId);
170
+ return {
171
+ task: { ...task, labels: [...task.labels] },
172
+ comments: [...(this.comments.get(taskId) ?? [])],
173
+ dependencies: this.dependencies.filter((d) => d.fromTaskId === taskId || d.toTaskId === taskId),
174
+ parent: task.parentTaskId ? { ...this.task(task.parentTaskId) } : undefined,
175
+ };
176
+ }
177
+
178
+ async addComment(taskId: number, body: string): Promise<TrackerComment> {
179
+ this.task(taskId);
180
+ const comment: TrackerComment = { id: this.nextId++, body, authorUserId: 1, createdAt: new Date() };
181
+ this.comments.set(taskId, [...(this.comments.get(taskId) ?? []), comment]);
182
+ this.log.push(`comment ${taskId}: ${body}`);
183
+ return comment;
184
+ }
185
+
186
+ async addInitiativeComment(initiativeId: number, body: string): Promise<TrackerComment> {
187
+ const comment: TrackerComment = { id: this.nextId++, body, authorUserId: 1, createdAt: new Date() };
188
+ this.initiativeComments.set(initiativeId, [...(this.initiativeComments.get(initiativeId) ?? []), comment]);
189
+ // The id alone: unlike a Task comment, the body here is a multi-line report, and a test that
190
+ // asserts the interaction log wants to see *that* a summary was posted. What it says is
191
+ // asserted through `initiativeComments`.
192
+ this.log.push(`initiative comment ${initiativeId}`);
193
+ return comment;
194
+ }
195
+
196
+ async moveTask(taskId: number, lifecycle: Lifecycle): Promise<TrackerColumn> {
197
+ const column = pickColumn(FAKE_COLUMNS, lifecycle);
198
+ if (!column) throw new Error(`Board has no column with lifecycle "${lifecycle}"`);
199
+ this.task(taskId).column = column;
200
+ this.log.push(`move ${taskId} -> ${lifecycle}`);
201
+ return column;
202
+ }
203
+
204
+ async attachLabel(taskId: number, label: string): Promise<void> {
205
+ if (!this.labels.has(label)) throw new Error(`Project ${this.projectId} has no active label "${label}"`);
206
+ const task = this.task(taskId);
207
+ if (task.labels.includes(label)) return;
208
+ task.labels.push(label);
209
+ this.log.push(`label ${taskId} +${label}`);
210
+ }
211
+
212
+ async detachLabel(taskId: number, label: string): Promise<void> {
213
+ const task = this.task(taskId);
214
+ if (!task.labels.includes(label)) return;
215
+ task.labels = task.labels.filter((l) => l !== label);
216
+ this.log.push(`label ${taskId} -${label}`);
217
+ }
218
+
219
+ async ensureLabel(_projectId: number, label: string): Promise<void> {
220
+ if (this.labels.has(label)) return;
221
+ this.labels.add(label);
222
+ this.log.push(`create label ${label}`);
223
+ }
224
+
225
+ async createTask(input: NewTask): Promise<TrackerTask> {
226
+ // The real adapter resolves a label and a task type by name and refuses a name the project
227
+ // does not define (`labelByName`, `pickNamed`); a caller that forgot `ensureLabel`, or asked
228
+ // for a type this project has not got, must fail here too.
229
+ for (const label of input.labels ?? []) {
230
+ if (!this.labels.has(label)) throw new TrackerError(`Project ${this.projectId} has no active label "${label}"`);
231
+ }
232
+ if (input.type !== undefined && this.taskTypes && !this.taskTypes.includes(input.type)) {
233
+ throw new TrackerError(`No task type named "${input.type}" (have: ${this.taskTypes.join(", ")})`, "unknown_task_type");
234
+ }
235
+ const created = this.addTask({
236
+ id: this.nextId++,
237
+ title: input.title,
238
+ description: input.description,
239
+ initiativeId: input.initiativeId,
240
+ parentTaskId: input.parentTaskId,
241
+ labels: input.labels,
242
+ blockedBy: input.blockedBy,
243
+ relatesTo: input.relatesTo,
244
+ type: input.type ?? this.taskTypes?.[0],
245
+ });
246
+ this.log.push(`create task ${created.identifier}: ${created.title}`);
247
+ return created;
248
+ }
249
+
250
+ async projectRepositories(): Promise<ProjectRepository[]> {
251
+ return [...this.repositories];
252
+ }
253
+
254
+ async linkPullRequest(taskId: number, pr: PullRequestLink): Promise<void> {
255
+ this.task(taskId);
256
+ if (!this.repositories.some((r) => r.id === pr.projectRepositoryId)) throw new Error(`Project ${this.projectId} has no repository ${pr.projectRepositoryId}`);
257
+ // The backend upserts on (task, repository, number): a re-link replaces the row.
258
+ const links = (this.pullRequests.get(taskId) ?? []).filter((l) => !(l.projectRepositoryId === pr.projectRepositoryId && l.number === pr.number));
259
+ this.pullRequests.set(taskId, [...links, { ...pr }]);
260
+ this.log.push(`link pr ${taskId}: ${pr.url}`);
261
+ }
262
+
263
+ async addMemory(initiativeId: number, entry: MemoryEntry): Promise<{ id: number }> {
264
+ const record = { id: this.nextId++, initiativeId, ...entry, isAgent: true, isSystem: false, createdAt: new Date() };
265
+ this.memory.push(record);
266
+ this.log.push(`memory ${initiativeId} ${entry.kind}${entry.sourceTaskId ? ` (task ${entry.sourceTaskId})` : ""}`);
267
+ return { id: record.id };
268
+ }
269
+
270
+ async listMemory(initiativeId: number, query: MemoryQuery = {}): Promise<MemoryRecord[]> {
271
+ return this.memory
272
+ .filter(
273
+ (m) =>
274
+ m.initiativeId === initiativeId &&
275
+ (!query.kinds || query.kinds.includes(m.kind)) &&
276
+ (query.sourceTaskId === undefined || m.sourceTaskId === query.sourceTaskId),
277
+ )
278
+ .sort((a, b) => b.id - a.id)
279
+ .map(({ initiativeId: _drop, ...rest }) => rest);
280
+ }
281
+
282
+ async appendRunEvents(initiativeId: number, inputs: RunEventInput[]): Promise<RunEvent[]> {
283
+ const appended = inputs.map((input) =>
284
+ create(RunEventSchema, {
285
+ id: this.nextId++,
286
+ runId: initiativeId,
287
+ initiativeId,
288
+ kind: input.payload?.payload.case ?? "",
289
+ taskId: input.taskId,
290
+ payload: input.payload,
291
+ isAgent: true,
292
+ }),
293
+ );
294
+ this.events.push(...appended);
295
+ for (const e of appended) this.log.push(`event ${e.kind}${e.taskId ? ` (task ${e.taskId})` : ""}`);
296
+ return appended;
297
+ }
298
+
299
+ /** The Initiative's Tasks, edges among them, and slices layered the way the server does: every
300
+ * Task, Done ones included, at one past its deepest blocker's layer (the server lays out the
301
+ * whole graph; readiness, not the slice, is what excludes a Done Task). */
302
+ async getInitiativeContext(initiativeId: number): Promise<InitiativeContext> {
303
+ const ini = this.initiatives.get(initiativeId);
304
+ if (!ini) throw new TrackerError(`Initiative ${initiativeId} not found`);
305
+ const tasks = [...this.tasks.values()].filter((t) => t.initiativeId === initiativeId).map((t) => ({ ...t, labels: [...t.labels] }));
306
+ const ids = new Set(tasks.map((t) => t.id));
307
+ const edges = this.dependencies.filter((d) => ids.has(d.fromTaskId) && ids.has(d.toTaskId)).map(({ fromTaskId, toTaskId, type }) => ({ fromTaskId, toTaskId, type }));
308
+ const layerOf = new Map<number, number>();
309
+ const layer = (id: number, seen: Set<number>): number => {
310
+ const known = layerOf.get(id);
311
+ if (known !== undefined) return known;
312
+ if (seen.has(id)) return 0;
313
+ seen.add(id);
314
+ const blockers = blockerIdsOf(id, this.dependencies).filter((b) => ids.has(b));
315
+ const value = blockers.length === 0 ? 0 : 1 + Math.max(...blockers.map((b) => layer(b, seen)));
316
+ layerOf.set(id, value);
317
+ return value;
318
+ };
319
+ for (const t of tasks) layer(t.id, new Set());
320
+ const slices: TrackerSlice[] = [];
321
+ for (const t of tasks) {
322
+ const l = layerOf.get(t.id) ?? 0;
323
+ let slice = slices.find((s) => s.layer === l);
324
+ if (!slice) slices.push((slice = { layer: l, taskIds: [] }));
325
+ slice.taskIds.push(t.id);
326
+ }
327
+ slices.sort((a, b) => a.layer - b.layer);
328
+ const pullRequests = [...this.pullRequests.entries()].flatMap(([taskId, links]) =>
329
+ ids.has(taskId) ? links.map((l) => ({ taskId, number: l.number, title: l.title, url: l.url, state: "open", repository: this.repositories.find((r) => r.id === l.projectRepositoryId)?.fullName })) : [],
330
+ );
331
+ return {
332
+ initiative: { id: ini.id, title: ini.title, summary: ini.summary || undefined, status: ini.status, coreIdea: ini.coreIdea, projectId: this.projectId },
333
+ tasks,
334
+ edges,
335
+ slices,
336
+ memory: await this.listMemory(initiativeId),
337
+ pullRequests,
338
+ };
339
+ }
340
+
341
+ async readRunRecord(initiativeId: number, query: RunRecordQuery = {}): Promise<RunRecord> {
342
+ const events = this.events.filter(
343
+ (e) =>
344
+ e.initiativeId === initiativeId &&
345
+ (query.afterId === undefined || e.id > query.afterId) &&
346
+ (query.taskId === undefined || e.taskId === query.taskId) &&
347
+ (!query.kinds || query.kinds.includes(e.kind)),
348
+ );
349
+ // A Run is created lazily by the first appended event and its status mirrors the Initiative's
350
+ // (initiative.proto), so the fake reports one exactly when the Record has something in it.
351
+ const all = this.events.filter((e) => e.initiativeId === initiativeId);
352
+ const run = all.length === 0 ? undefined : create(RunSchema, { id: initiativeId, initiativeId, status: this.initiatives.get(initiativeId)?.status ?? "active", eventCount: BigInt(all.length) });
353
+ return { run, events };
354
+ }
355
+ }
@@ -0,0 +1,221 @@
1
+ import { completedEvent, type CompletionReport } from "./completion-report.js";
2
+ import type { AdapterEvent, InteractiveCommand, InteractiveSessionRequest, TurnHandle, TurnRequest, WorkerAdapter } from "./worker-adapter.js";
3
+
4
+ /**
5
+ * A Worker Adapter that replays scripts instead of running a vendor CLI. Every later Foreman test
6
+ * (scheduling, Gates, Verifiers) drives the core through this so the LLM is out of the loop
7
+ * (spec, "Testing Decisions"). A script is the event list one turn yields, in order, ending in a
8
+ * terminal event; a script may also be a function, for turns that must wait or react to `stop()`.
9
+ */
10
+ export type FakeTurnScript = AdapterEvent[] | ((control: FakeTurnControl) => AsyncIterable<AdapterEvent>);
11
+
12
+ export interface FakeTurnControl {
13
+ /** Resolves when `stop()` is called on the handle, for scripts that hang until stopped. */
14
+ stopped: Promise<void>;
15
+ /** The request this turn was launched or resumed with. */
16
+ request: TurnRequest;
17
+ }
18
+
19
+ /** Scripts a turn that starts and reports the given outcome. Defaults are a minimal valid report. */
20
+ export function scriptedTurn(sessionId: string, report: Partial<CompletionReport> = {}, before: AdapterEvent[] = []): AdapterEvent[] {
21
+ const full: CompletionReport = {
22
+ outcome: "done",
23
+ summary: "Scripted turn finished.",
24
+ decisions: [],
25
+ tried: [],
26
+ blockers: [],
27
+ proposedLearnings: [],
28
+ ...report,
29
+ };
30
+ return [{ type: "started", sessionId }, ...before, { type: "completed", sessionId, report: full }];
31
+ }
32
+
33
+ /** Scripts a turn that starts and ends with `structured` as its final message: what a turn under a
34
+ * contract other than the Completion Report (the Verifier's Verdict) hands back. The fake holds it
35
+ * to the request's contract the way the vendor adapters do (`replay`), so a script handing in a
36
+ * malformed object ends the turn as `invalid_report`. */
37
+ export function structuredTurn(sessionId: string, structured: unknown, before: AdapterEvent[] = []): AdapterEvent[] {
38
+ const [started, completed] = scriptedTurn(sessionId);
39
+ return [started, ...before, { ...(completed as Extract<AdapterEvent, { type: "completed" }>), structured }];
40
+ }
41
+
42
+ /** Scripts a turn the vendor refused on a rate limit. */
43
+ export function rateLimitedTurn(sessionId: string, resetAt?: string): AdapterEvent[] {
44
+ const message = "Scripted rate limit";
45
+ return [
46
+ { type: "started", sessionId },
47
+ { type: "rate_limited", resetAt, message },
48
+ { type: "failed", reason: "rate_limited", message, sessionId },
49
+ ];
50
+ }
51
+
52
+ /** Scripts a turn the vendor refused because the Account's credential is dead (OPS-288). No event
53
+ * precedes the failure, unlike a rate limit's: there is nothing for the Run to learn mid-turn, and
54
+ * the decision to quarantine is made from the terminal event alone. */
55
+ export function credentialFailedTurn(sessionId: string, message = "Scripted dead credential"): AdapterEvent[] {
56
+ return [
57
+ { type: "started", sessionId },
58
+ { type: "failed", reason: "credential_failed", message, sessionId },
59
+ ];
60
+ }
61
+
62
+ /** Scripts a turn that produces output and then goes silent until stopped or the stall timer fires.
63
+ * The fake does not run a real timer: with `stallTimeoutMs` set it reports the stall at once. */
64
+ export function stallingTurn(sessionId: string): FakeTurnScript {
65
+ return async function* (control) {
66
+ yield { type: "started", sessionId };
67
+ yield { type: "output", text: "working..." };
68
+ const stallMs = control.request.stallTimeoutMs;
69
+ if (stallMs !== undefined) {
70
+ yield { type: "stalled", silentMs: stallMs };
71
+ yield { type: "failed", reason: "stalled", message: `No output for ${stallMs}ms`, sessionId };
72
+ return;
73
+ }
74
+ await control.stopped;
75
+ yield { type: "failed", reason: "stopped", message: "Stopped before the Worker reported", sessionId };
76
+ };
77
+ }
78
+
79
+ /** A turn a test finishes when it chooses, so concurrency is deterministic rather than raced: the
80
+ * scheduler's tests hold several turns open at once, look at how many are running, and then let
81
+ * them report one at a time. `started` resolves as the turn begins (the Foreman has taken a Slot
82
+ * for it), and the turn ends only once `finish` is called. */
83
+ export interface GatedTurn {
84
+ script: FakeTurnScript;
85
+ /** Resolves the moment the Foreman launched this turn: it has taken a Slot for it. */
86
+ started: Promise<void>;
87
+ /** True between the launch and the report: what a test counts to see how many turns are running. */
88
+ readonly running: boolean;
89
+ /** Lets the turn report, ending it. */
90
+ finish: (report?: Partial<CompletionReport>) => void;
91
+ }
92
+
93
+ /** A turn a test ends when it chooses, so concurrency is deterministic rather than raced: the
94
+ * scheduler's tests hold several turns open at once, count how many are running, and then let them
95
+ * report one at a time. */
96
+ export function gatedTurn(sessionId: string): GatedTurn {
97
+ let began!: () => void;
98
+ const started = new Promise<void>((resolve) => {
99
+ began = resolve;
100
+ });
101
+ let release!: (report: Partial<CompletionReport>) => void;
102
+ const gate = new Promise<Partial<CompletionReport>>((resolve) => {
103
+ release = resolve;
104
+ });
105
+ const turn = {
106
+ running: false,
107
+ started,
108
+ finish: (report: Partial<CompletionReport> = {}) => release(report),
109
+ script: async function* (): AsyncIterable<AdapterEvent> {
110
+ yield { type: "started", sessionId };
111
+ turn.running = true;
112
+ began();
113
+ const report = await gate;
114
+ turn.running = false;
115
+ yield* scriptedTurn(sessionId, report).slice(1);
116
+ },
117
+ };
118
+ return turn;
119
+ }
120
+
121
+ export class FakeWorkerAdapter implements WorkerAdapter {
122
+ /** Every request seen, in order, so a test can assert what the Foreman asked for. */
123
+ readonly launches: TurnRequest[] = [];
124
+ readonly resumes: { sessionId: string; request: TurnRequest }[] = [];
125
+ /** Every interactive command asked for (`foreman attach`), in order. */
126
+ readonly interactives: { sessionId: string; request: Pick<TurnRequest, "account" | "cwd"> }[] = [];
127
+ /** Every interactive session asked for (`foreman plan`), in order. */
128
+ readonly sessions: InteractiveSessionRequest[] = [];
129
+ private readonly scripts: FakeTurnScript[];
130
+ /** Chooses a script from the request rather than from the queue: what a test needs once turns
131
+ * overlap, since the queue's order is then the very thing under test. Consulted first; a request
132
+ * it has no script for falls back to the queue. */
133
+ scriptFor?: (request: TurnRequest) => FakeTurnScript | undefined;
134
+
135
+ constructor(scripts: FakeTurnScript[] = []) {
136
+ this.scripts = [...scripts];
137
+ }
138
+
139
+ /** Queues the script the next `launch` or `resume` will replay. */
140
+ enqueue(script: FakeTurnScript): this {
141
+ this.scripts.push(script);
142
+ return this;
143
+ }
144
+
145
+ launch(request: TurnRequest): TurnHandle {
146
+ this.launches.push(request);
147
+ return this.replay(request);
148
+ }
149
+
150
+ resume(sessionId: string, request: TurnRequest): TurnHandle {
151
+ this.resumes.push({ sessionId, request });
152
+ return this.replay(request);
153
+ }
154
+
155
+ /** A command a test can recognise and never run: `fake-worker --resume <id>` in the Workspace. */
156
+ interactiveCommand(sessionId: string, request: Pick<TurnRequest, "account" | "cwd">): InteractiveCommand {
157
+ this.interactives.push({ sessionId, request });
158
+ return { command: "fake-worker", args: ["--resume", sessionId], cwd: request.cwd, env: { FAKE_ACCOUNT: request.account.name } };
159
+ }
160
+
161
+ /** `fake-worker --prompt <prompt>` in the Workspace: recognisable, never run. */
162
+ interactiveSession(request: InteractiveSessionRequest): InteractiveCommand {
163
+ this.sessions.push(request);
164
+ return { command: "fake-worker", args: ["--prompt", request.prompt], cwd: request.cwd, env: { FAKE_ACCOUNT: request.account.name } };
165
+ }
166
+
167
+ private replay(request: TurnRequest): TurnHandle {
168
+ const script = this.scriptFor?.(request) ?? this.scripts.shift();
169
+ let stop!: () => void;
170
+ const stopped = new Promise<void>((resolve) => {
171
+ stop = resolve;
172
+ });
173
+ const control: FakeTurnControl = { request, stopped };
174
+ let stopRequested = false;
175
+
176
+ const source: AsyncIterable<AdapterEvent> =
177
+ script === undefined
178
+ ? (async function* () {
179
+ yield { type: "failed", reason: "launch_failed", message: "FakeWorkerAdapter has no script queued for this turn" } as AdapterEvent;
180
+ })()
181
+ : typeof script === "function"
182
+ ? script(control)
183
+ : (async function* () {
184
+ yield* script;
185
+ })();
186
+
187
+ // Stopping cuts the replay off at the next event and substitutes the `stopped` failure, the way a
188
+ // killed process would end the real stream; a script that already reached its terminal event is
189
+ // left alone. A request under a contract (the Verifier's) has its final message judged by that
190
+ // contract, as the vendor adapters judge theirs: a script's `structured` (or its report, when it
191
+ // scripted none) is parsed, and a malformed one ends the turn as `invalid_report`.
192
+ const contract = request.contract;
193
+ const events: AsyncIterable<AdapterEvent> = (async function* () {
194
+ for await (const event of source) {
195
+ if (stopRequested && event.type !== "completed" && event.type !== "failed") {
196
+ yield { type: "failed", reason: "stopped", message: "Stopped before the Worker reported" };
197
+ return;
198
+ }
199
+ if (event.type === "completed" && contract) {
200
+ const parsed = contract.parse(event.structured ?? event.report);
201
+ if (!parsed.ok) {
202
+ yield { type: "failed", reason: "invalid_report", message: `The Worker's final message is not a ${contract.name}`, sessionId: event.sessionId, details: parsed.errors };
203
+ return;
204
+ }
205
+ yield { ...event, ...completedEvent(contract, parsed.value, event.sessionId) };
206
+ return;
207
+ }
208
+ yield event;
209
+ if (event.type === "completed" || event.type === "failed") return;
210
+ }
211
+ })();
212
+
213
+ return {
214
+ events,
215
+ async stop() {
216
+ stopRequested = true;
217
+ stop();
218
+ },
219
+ };
220
+ }
221
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What kind of machine the daemon is on (story 17). A laptop sleeps: the lid closes, the display
3
+ * timer fires, and every Worker in flight is suspended mid-turn; Reconcile resumes them on wake,
4
+ * but the Run stalls for as long as the lid is down. `foreman up` warns when it finds an internal
5
+ * battery, which is the one signal that separates a laptop from a desktop or a server, and says
6
+ * how to keep the machine awake. Detection is a probe with injected commands and file system so a
7
+ * test can be a laptop or a server at will.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+
12
+ export interface HostProbeDeps {
13
+ platform: NodeJS.Platform;
14
+ /** Runs a command and resolves with its stdout; rejects when it is missing or fails. */
15
+ exec: (file: string, args: string[]) => Promise<string>;
16
+ /** Lists a directory, or throws when it does not exist. */
17
+ readdir: (dir: string) => string[];
18
+ /** Reads a small file as text, or throws when it does not exist. */
19
+ readFile: (path: string) => string;
20
+ }
21
+
22
+ /** Where Linux exposes power supplies. Each entry has a `type`; an internal battery is one of type
23
+ * `Battery` whose `scope` is not `Device` (a mouse's or a headset's is), whatever its name: BAT0
24
+ * on most, macsmc-battery, axp20x-battery, sbs-* and bq27xxx-* elsewhere. */
25
+ export const LINUX_POWER_SUPPLY_DIR = "/sys/class/power_supply";
26
+
27
+ /** True when the machine has an internal battery, false for a desktop, a server, or an unknown
28
+ * platform. A probe that fails (no `pmset`, no sysfs) reads as not a laptop: the warning is a
29
+ * courtesy, and a false one on every server would teach people to ignore it. */
30
+ export async function hostIsLaptop(deps: HostProbeDeps): Promise<boolean> {
31
+ try {
32
+ if (deps.platform === "darwin") {
33
+ const batt = await deps.exec("pmset", ["-g", "batt"]);
34
+ if (batt.includes("InternalBattery")) return true;
35
+ const ioreg = await deps.exec("ioreg", ["-r", "-c", "AppleSmartBattery"]);
36
+ return ioreg.includes("AppleSmartBattery");
37
+ }
38
+ if (deps.platform === "linux") {
39
+ return deps.readdir(LINUX_POWER_SUPPLY_DIR).some((name) => linuxSupplyIsInternalBattery(deps, join(LINUX_POWER_SUPPLY_DIR, name)));
40
+ }
41
+ } catch {
42
+ // Fall through: an unanswerable probe is not a laptop.
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /** One power supply: a battery by its `type`, internal by a `scope` that is not `Device` (absent
48
+ * counts as internal; the kernel writes it only for peripherals). An entry that cannot be read is
49
+ * not a battery. */
50
+ function linuxSupplyIsInternalBattery(deps: HostProbeDeps, dir: string): boolean {
51
+ try {
52
+ if (deps.readFile(join(dir, "type")).trim() !== "Battery") return false;
53
+ } catch {
54
+ return false;
55
+ }
56
+ try {
57
+ return deps.readFile(join(dir, "scope")).trim() !== "Device";
58
+ } catch {
59
+ return true;
60
+ }
61
+ }
62
+
63
+ /** The warning `foreman up` prints on a laptop: the risk, and the ways out. */
64
+ export function sleepWarning(platform: NodeJS.Platform): string[] {
65
+ const keepAwake =
66
+ platform === "darwin"
67
+ ? "run it under caffeinate (caffeinate -i opsee foreman up), or turn off sleep in System Settings > Battery while it works"
68
+ : platform === "linux"
69
+ ? "run it under systemd-inhibit (systemd-inhibit --what=sleep opsee foreman up), or turn off suspend in your power settings while it works"
70
+ : "turn off sleep while it works";
71
+ return [
72
+ "Warning: this machine has a battery, so it is a laptop that may sleep. Sleep suspends every Worker mid-turn; Reconcile resumes them on wake, but the Run stalls until then.",
73
+ `To keep it going: ${keepAwake}, or move the Run to an always-on host (opsee foreman service install there).`,
74
+ ];
75
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Where the Foreman keeps what is meaningful only on this machine (ADR-0009): the Process Table,
6
+ * the daemon's pid file and the Worker transcripts. All under `~/.opsee/`, beside the credentials
7
+ * file the MCP server keeps and the Accounts file (account-store.ts), each with its own override
8
+ * so a test can point one at a temporary directory without touching the others.
9
+ */
10
+ export function foremanLocalDir(): string {
11
+ return process.env.OPSEE_FOREMAN_LOCAL_DIR || join(homedir(), ".opsee");
12
+ }
13
+
14
+ /** The SQLite Process Table (core/process-table.ts). */
15
+ export function processTablePath(): string {
16
+ return process.env.OPSEE_FOREMAN_PROCESS_TABLE_PATH || join(foremanLocalDir(), "foreman-process-table.sqlite");
17
+ }
18
+
19
+ /** Where each turn's raw adapter event stream is written (core/transcripts.ts): the transcript
20
+ * never leaves the machine (story 49). */
21
+ export function transcriptsDir(): string {
22
+ return process.env.OPSEE_FOREMAN_TRANSCRIPTS_PATH || join(foremanLocalDir(), "transcripts");
23
+ }
24
+
25
+ /** The daemon's pid file (commands/foreman.ts): how `foreman run` tells whether a daemon is up. */
26
+ export function daemonPidPath(): string {
27
+ return process.env.OPSEE_FOREMAN_PID_PATH || join(foremanLocalDir(), "foreman.pid");
28
+ }