@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,242 @@
1
+ /**
2
+ * The human's controls on a Run (stories 53-56): `opsee foreman attach <task>`, `release <task>`,
3
+ * `pause <initiative>`, `resume <initiative>` and `cancel <task>`. CLI only, by design: the daemon
4
+ * holds the subscriptions, and nothing on a web page controls it (spec, "Out of Scope").
5
+ *
6
+ * The daemon (or a foreground `foreman run`) owns the Workers; these commands run in another
7
+ * process and speak to it through the Process Table, the machine-local control channel
8
+ * (ADR-0009): a mark on the Worker's row, or a pause row for the Initiative. The command that
9
+ * marks a row is also the one that stops the process, by the pid the row records, so the human
10
+ * sees the Worker gone before the command returns, and it forgets that pid once the process is
11
+ * gone; the process that owns the turn reads the mark when the stream ends and settles accordingly
12
+ * (core/run.ts), and Reconcile does the same for a row nobody owns (core/reconcile.ts). The mark
13
+ * and the owner's settlement can still cross: a turn that ends on its own while the command runs.
14
+ * So the mark is refused when the row is already gone, and when nothing was stopped the command
15
+ * confirms the row is still there and the Task still wears `foreman:running` before going on.
16
+ * Every control lands on the Run Record as a `control` event, through the outbox so an unreachable
17
+ * backend queues it; a backend that refuses the event is reported, but never undoes what was done
18
+ * on the machine, since the human asked for exactly that.
19
+ *
20
+ * Attach is the one control with a second half: once the unattended turn is stopped, this process
21
+ * runs the vendor's own interactive resume of the same session in the Workspace (the Worker
22
+ * Adapter's `interactiveCommand`), with the terminal inherited, and waits for the human to leave
23
+ * it. The command is built before anything is marked or stopped (an Account whose key is unset
24
+ * fails there), and a session that cannot be opened (no vendor binary) hands the Worker straight
25
+ * back, so Reconcile resumes it. The row stays `attached` until `release`, so Reconcile never
26
+ * resumes it underneath them.
27
+ */
28
+ import { spawn, type ChildProcess } from "node:child_process";
29
+ import { AccountError, type Account } from "../foreman/account.js";
30
+ import type { AccountStore } from "../foreman/account-store.js";
31
+ import { OutboxTracker } from "../foreman/core/outbox-tracker.js";
32
+ import type { WorkerRow } from "../foreman/core/process-table.js";
33
+ import { controlEvent, ForemanError, STATUS_LABELS, type ControlAction } from "../foreman/core/run.js";
34
+ import { stopProcessByPid } from "../foreman/process-group.js";
35
+ import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
36
+ import type { InteractiveCommand, WorkerAdapter } from "../foreman/worker-adapter.js";
37
+ import { errorMessage, KILL_GRACE_MS } from "../foreman/worker-process.js";
38
+ import type { ForemanLocal } from "./foreman.js";
39
+
40
+ export interface ForemanControlDeps {
41
+ tracker: TrackerAdapter;
42
+ store: AccountStore;
43
+ local: ForemanLocal;
44
+ adapterFor: (account: Account) => WorkerAdapter;
45
+ out: (line: string) => void;
46
+ /** Stops the Worker a row names, from this process; true when something was running, false when
47
+ * nothing alive was the Worker (a dead pid, or one the OS reused), and it throws when the stop
48
+ * could not be carried out (`StopRefusedError`). The process-group kill by the row's pid by
49
+ * default; the tests stop the fake's handle instead. */
50
+ stopWorker?: (row: WorkerRow) => Promise<boolean>;
51
+ /** Runs the attended turn with the terminal inherited and resolves to its exit code; rejects when
52
+ * the vendor's command could not start. */
53
+ runInteractive?: (command: InteractiveCommand) => Promise<number>;
54
+ now?: () => number;
55
+ }
56
+
57
+ /** Stops the Worker, and the app a Verifier round has running for the Task (`appPid`), which the
58
+ * Worker's process group does not contain: the app was spawned by the Foreman, not by the Worker. */
59
+ const defaultStopWorker = async (row: WorkerRow): Promise<boolean> => {
60
+ const worker = row.pid === undefined ? false : await stopProcessByPid(row.pid, KILL_GRACE_MS, { startedAt: row.startedAt });
61
+ const app = row.appPid === undefined ? false : await stopProcessByPid(row.appPid, KILL_GRACE_MS, { startedAt: row.startedAt });
62
+ return worker || app;
63
+ };
64
+
65
+ /** Runs an attended session in this terminal, stdio inherited, and resolves to its exit code;
66
+ * rejects when the vendor's command could not start. Shared with `foreman plan`, which asks for
67
+ * the child through `onSpawn` so a signal to this process can be forwarded to the session. */
68
+ export const runInteractiveCommand = (command: InteractiveCommand, options: { onSpawn?: (child: ChildProcess) => void } = {}): Promise<number> =>
69
+ new Promise((resolve, reject) => {
70
+ const child = spawn(command.command, command.args, { cwd: command.cwd, env: command.env, stdio: "inherit" });
71
+ options.onSpawn?.(child);
72
+ child.once("error", (error) => reject(new ForemanError(`Could not start ${command.command}: ${errorMessage(error)}`)));
73
+ child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
74
+ });
75
+
76
+ /** The Worker row for a Task, or a refusal that says how to find one. */
77
+ function rowOf(deps: ForemanControlDeps, taskId: number, verb: string): WorkerRow {
78
+ const row = deps.local.table.worker(taskId);
79
+ if (!row) {
80
+ throw new ForemanError(`No Worker on Task ${taskId} to ${verb}: the Process Table ${deps.local.table.path} has no row for it. Only a Task the Foreman has dispatched and not yet settled can be ${verb === "cancel" ? "cancelled" : `${verb}ed`}.`);
81
+ }
82
+ return row;
83
+ }
84
+
85
+ /** Appends the control event, through the outbox; a refusal is reported and does not undo the
86
+ * machine-side change the human asked for. */
87
+ async function record(deps: ForemanControlDeps, initiativeId: number, action: ControlAction, message: string, taskId?: number): Promise<void> {
88
+ const tracker = new OutboxTracker(deps.tracker, deps.local.table, deps.out);
89
+ try {
90
+ await tracker.appendRunEvents(initiativeId, [controlEvent(action, message, taskId)]);
91
+ } catch (error) {
92
+ deps.out(`control: ${action} done on this machine, but the Run Record did not take the control event: ${errorMessage(error)}`);
93
+ }
94
+ }
95
+
96
+ /** Puts the mark on the row, or refuses when the row is gone: the turn ended on its own between
97
+ * the read and the mark, and its owner has recorded the attempt. */
98
+ function mark(deps: ForemanControlDeps, row: WorkerRow, control: "attached" | "cancelled", verb: string): void {
99
+ if (!deps.local.table.setControl(row.taskId, control, (deps.now ?? Date.now)())) {
100
+ throw new ForemanError(`Task ${row.identifier}'s turn ended as the ${verb} ran: its attempt ${row.attempt} was recorded by the process that owned it, so there is nothing to ${verb}.`);
101
+ }
102
+ }
103
+
104
+ /** Stops the Worker and forgets its pid, which named nothing of the Worker's any more once the stop
105
+ * resolved (dead, or reused by the OS). A refused stop takes the mark back off and is rethrown:
106
+ * nothing changed on the machine. */
107
+ async function stopAndForget(deps: ForemanControlDeps, row: WorkerRow): Promise<boolean> {
108
+ let stopped: boolean;
109
+ try {
110
+ stopped = await (deps.stopWorker ?? defaultStopWorker)(row);
111
+ } catch (error) {
112
+ deps.local.table.setControl(row.taskId, row.control, (deps.now ?? Date.now)());
113
+ throw error;
114
+ }
115
+ if (row.pid !== undefined) deps.local.table.clearPid(row.taskId);
116
+ if (row.appPid !== undefined) deps.local.table.setAppPid(row.taskId, undefined);
117
+ return stopped;
118
+ }
119
+
120
+ /** Nothing was stopped, so the mark may have landed on a turn that was ending on its own: the row
121
+ * must still be there and the Task must still wear `foreman:running`, else the attempt has been
122
+ * recorded and the mark is taken back off. */
123
+ async function confirmStillOpen(deps: ForemanControlDeps, row: WorkerRow, verb: string): Promise<void> {
124
+ const ended = `Task ${row.identifier}'s turn ended as the ${verb} ran: its attempt ${row.attempt} was recorded by the process that owned it, so there is nothing to ${verb}.`;
125
+ if (!deps.local.table.worker(row.taskId)) throw new ForemanError(ended);
126
+ const { task } = await deps.tracker.getTask(row.taskId);
127
+ if (!task.labels.includes(STATUS_LABELS.running)) {
128
+ deps.local.table.setControl(row.taskId, row.control, (deps.now ?? Date.now)());
129
+ throw new ForemanError(ended);
130
+ }
131
+ }
132
+
133
+ /** `opsee foreman attach <taskId>` (story 53). */
134
+ export async function runForemanAttach(deps: ForemanControlDeps, taskId: number): Promise<number> {
135
+ const { out } = deps;
136
+ const now = deps.now ?? Date.now;
137
+ const row = rowOf(deps, taskId, "attach");
138
+ const account = deps.store.load().find((a) => a.name === row.account);
139
+ if (!account) throw new AccountError(`Task ${row.identifier} runs under Account "${row.account}", which is no longer registered; register it again to attach.`);
140
+ if (row.control === "cancelled") throw new ForemanError(`Task ${row.identifier} was cancelled (opsee foreman cancel ${taskId}); its attempt is being closed, so there is nothing to attach to.`);
141
+ if (!row.sessionId) {
142
+ throw new ForemanError(`Task ${row.identifier} has no session id yet: the Worker has not said which session it is, so nothing can be resumed. Wait for its first line, or cancel it.`);
143
+ }
144
+ // The command first: an Account whose key is not set in this environment fails here, before any
145
+ // mark is made or anything is stopped.
146
+ const command = deps.adapterFor(account).interactiveCommand(row.sessionId, { account, cwd: row.workspace });
147
+
148
+ const alreadyAttached = row.control === "attached";
149
+ if (alreadyAttached) {
150
+ out(`attach: ${row.identifier} is already attached; opening the session again`);
151
+ } else {
152
+ mark(deps, row, "attached", "attach");
153
+ const stopped = await stopAndForget(deps, row);
154
+ if (!stopped) await confirmStillOpen(deps, row, "attach");
155
+ out(`attach: ${row.identifier} attempt ${row.attempt}: ${stopped ? `unattended turn stopped (pid ${row.pid})` : "no unattended turn was running"}; the attempt stays open`);
156
+ await record(deps, row.initiativeId, "attach", `attached to attempt ${row.attempt} in ${row.workspace}${stopped ? "; the unattended turn was stopped" : ""}`, taskId);
157
+ }
158
+
159
+ out(`attach: resuming session ${row.sessionId} as an attended turn: ${[command.command, ...command.args].join(" ")} in ${command.cwd} (Account "${account.name}")`);
160
+ let code: number;
161
+ try {
162
+ code = await (deps.runInteractive ?? runInteractiveCommand)(command);
163
+ } catch (error) {
164
+ if (alreadyAttached) throw error;
165
+ // The session never opened, so no attended turn happened: hand the Worker straight back and
166
+ // let the next tick resume it unattended, rather than leave it attached to nobody. The row
167
+ // goes to `released`, not blank: the owner of the stopped turn may not have read the mark yet,
168
+ // and either mark tells it to leave the attempt open, where a blank row would settle it as
169
+ // failed.
170
+ deps.local.table.setControl(taskId, "released", now());
171
+ const message = errorMessage(error);
172
+ out(`attach: could not open the session (${message}); ${row.identifier} is handed back, and the next Foreman tick resumes session ${row.sessionId} unattended in ${row.workspace}`);
173
+ await record(deps, row.initiativeId, "release", `attach could not open the session (${message}); handed back to the Foreman, which resumes attempt ${row.attempt} unattended`, taskId);
174
+ throw error;
175
+ }
176
+ out(`attach: attended turn on ${row.identifier} over (exit ${code}); the Worker stays attached to you: opsee foreman release ${taskId} hands it back, opsee foreman cancel ${taskId} stops it for good`);
177
+ return 0;
178
+ }
179
+
180
+ /** `opsee foreman release <taskId>` (story 54). */
181
+ export async function runForemanRelease(deps: ForemanControlDeps, taskId: number): Promise<number> {
182
+ const now = deps.now ?? Date.now;
183
+ const row = rowOf(deps, taskId, "release");
184
+ if (row.control !== "attached") {
185
+ throw new ForemanError(`Task ${row.identifier} is not attached${row.control ? ` (it is ${row.control})` : ""}; only an attached Worker can be released.`);
186
+ }
187
+ deps.local.table.setControl(taskId, "released", now());
188
+ deps.out(`release: ${row.identifier} attempt ${row.attempt} handed back; the next Foreman tick resumes session ${row.sessionId ?? "(none)"} unattended in ${row.workspace}`);
189
+ await record(deps, row.initiativeId, "release", `released attempt ${row.attempt} after the attended turn; the next tick resumes it unattended`, taskId);
190
+ return 0;
191
+ }
192
+
193
+ /** `opsee foreman cancel <taskId>` (story 56). */
194
+ export async function runForemanCancel(deps: ForemanControlDeps, taskId: number): Promise<number> {
195
+ const { out } = deps;
196
+ const row = rowOf(deps, taskId, "cancel");
197
+ if (row.control === "cancelled") {
198
+ out(`cancel: ${row.identifier} is already cancelled; its attempt is closed by the process that owns the turn, or by the next Foreman tick`);
199
+ return 0;
200
+ }
201
+ mark(deps, row, "cancelled", "cancel");
202
+ const closer = `the attempt is recorded as cancelled (outcome stopped) and the Task blocked by the process that owns the turn, or by the next Foreman tick (foreman up, or foreman run ${row.initiativeId})`;
203
+ if (row.control === "attached") {
204
+ // The unattended turn is long stopped and the session is the human's own interactive one, in
205
+ // their terminal, not a pid this command knows; it is theirs to leave.
206
+ out(`cancel: ${row.identifier} attempt ${row.attempt} is attached to you, so nothing was signalled: leave the interactive session yourself; ${closer}`);
207
+ await record(deps, row.initiativeId, "cancel", `cancelled attempt ${row.attempt} while attached; the attended session is the human's to leave`, taskId);
208
+ return 0;
209
+ }
210
+ const stopped = await stopAndForget(deps, row);
211
+ if (!stopped) await confirmStillOpen(deps, row, "cancel");
212
+ out(`cancel: ${row.identifier} attempt ${row.attempt}: ${stopped ? `Worker stopped (pid ${row.pid})` : "no Worker process was running"}; ${closer}`);
213
+ await record(deps, row.initiativeId, "cancel", `cancelled attempt ${row.attempt}${stopped ? "; the Worker was stopped" : "; no Worker process was running"}`, taskId);
214
+ return 0;
215
+ }
216
+
217
+ /** `opsee foreman pause <initiativeId>` (story 55). */
218
+ export async function runForemanPause(deps: ForemanControlDeps, initiativeId: number): Promise<number> {
219
+ const now = deps.now ?? Date.now;
220
+ if (deps.local.table.isPaused(initiativeId)) {
221
+ deps.out(`pause: Initiative ${initiativeId} is already paused`);
222
+ return 0;
223
+ }
224
+ deps.local.table.pause(initiativeId, now());
225
+ // In flight: the Workers nobody has steered. An attached one is the human's, a released one waits
226
+ // for a tick, a cancelled one is being closed; none of them is work the pause lets finish.
227
+ const inFlight = deps.local.table.liveWorkers({ initiativeId }).filter((w) => w.control === undefined).length;
228
+ deps.out(`pause: Initiative ${initiativeId} paused; no new dispatch until foreman resume ${initiativeId}${inFlight ? `, ${inFlight} in-flight ${inFlight === 1 ? "Worker finishes" : "Workers finish"}` : ""}`);
229
+ await record(deps, initiativeId, "pause", `paused new dispatch${inFlight ? `; ${inFlight} in flight` : ""}`);
230
+ return 0;
231
+ }
232
+
233
+ /** `opsee foreman resume <initiativeId>` (story 55). */
234
+ export async function runForemanResume(deps: ForemanControlDeps, initiativeId: number): Promise<number> {
235
+ if (!deps.local.table.resume(initiativeId)) {
236
+ deps.out(`resume: Initiative ${initiativeId} is not paused`);
237
+ return 0;
238
+ }
239
+ deps.out(`resume: Initiative ${initiativeId} resumed; dispatch continues on the next Foreman tick`);
240
+ await record(deps, initiativeId, "resume", "resumed new dispatch");
241
+ return 0;
242
+ }
@@ -0,0 +1,131 @@
1
+ import { resolve } from "node:path";
2
+ import type { DebugTurnArgs } from "../args.js";
3
+ import { AccountError, type Account } from "../foreman/account.js";
4
+ import type { AccountStore } from "../foreman/account-store.js";
5
+ import { loadRunRecipe, startApp, waitForReady, type RunRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
6
+ import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
7
+ import type { TurnRequest, WorkerAdapter } from "../foreman/worker-adapter.js";
8
+
9
+ export interface ForemanDebugTurnDeps {
10
+ store: AccountStore;
11
+ /** The Worker Adapter for an Account's vendor (Claude Code or Codex). */
12
+ adapterFor: (account: Account, options: { raw: boolean }) => WorkerAdapter;
13
+ isDirectory: (path: string) => boolean;
14
+ out: (line: string) => void;
15
+ }
16
+
17
+ /** `opsee foreman debug turn`: one unattended turn (or a resumed one) under a registered Account,
18
+ * pinned to `--cwd`, with every adapter event printed and the Completion Report last. Exit code 0
19
+ * when the Worker reported, 1 otherwise. Nothing about the turn is written anywhere: this is the
20
+ * manual check of the Worker Adapter, not a Run. */
21
+ export async function runForemanDebugTurn(deps: ForemanDebugTurnDeps, args: DebugTurnArgs): Promise<number> {
22
+ const account = deps.store.load().find((a) => a.name === args.account);
23
+ if (!account) throw new AccountError(`No Account named "${args.account}"`);
24
+ const cwd = resolve(args.cwd);
25
+ if (!deps.isDirectory(cwd)) throw new AccountError(`--cwd is not a directory: ${cwd}`);
26
+
27
+ const request: TurnRequest = { account, cwd, prompt: args.prompt, maxTurns: args.maxTurns, stallTimeoutMs: args.stallTimeoutMs };
28
+ const adapter = deps.adapterFor(account, { raw: args.raw });
29
+ const handle = args.resume ? adapter.resume(args.resume, request) : adapter.launch(request);
30
+ deps.out(`${args.resume ? "Resuming" : "Launching"} ${account.vendor} Worker on Account "${account.name}" in ${cwd}`);
31
+
32
+ for await (const event of handle.events) {
33
+ switch (event.type) {
34
+ case "started":
35
+ deps.out(`session ${event.sessionId}`);
36
+ break;
37
+ case "output":
38
+ for (const line of event.text.split("\n")) deps.out(` | ${line}`);
39
+ break;
40
+ case "tool":
41
+ deps.out(` tool ${event.name}`);
42
+ break;
43
+ case "rate_limited":
44
+ deps.out(` rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
45
+ break;
46
+ case "stalled":
47
+ deps.out(` stalled: no output for ${event.silentMs}ms`);
48
+ break;
49
+ case "completed": {
50
+ const cost = event.costUsd === undefined ? "" : `, $${event.costUsd.toFixed(4)}`;
51
+ const turns = event.numTurns === undefined ? "" : `, ${event.numTurns} turns`;
52
+ deps.out(`Completion Report (${event.report.outcome}${turns}${cost}):`);
53
+ for (const line of JSON.stringify(event.report, null, 2).split("\n")) deps.out(line);
54
+ deps.out(`Resume with --resume ${event.sessionId}`);
55
+ return 0;
56
+ }
57
+ case "failed":
58
+ deps.out(`Turn failed (${event.reason}): ${event.message}`);
59
+ for (const detail of event.details ?? []) deps.out(` ${detail}`);
60
+ if (event.sessionId) deps.out(`Resume with --resume ${event.sessionId}`);
61
+ return 1;
62
+ }
63
+ }
64
+ deps.out("Turn ended without a terminal event");
65
+ return 1;
66
+ }
67
+
68
+ export interface ForemanDebugServeDeps {
69
+ /** The repo whose Run Recipe is used: the cwd. */
70
+ root: string;
71
+ out: (line: string) => void;
72
+ /** Resolves when the user asks to stop (SIGINT in the real command). */
73
+ untilStop: Promise<void>;
74
+ /** How long to wait on the readiness URL; default 120s, a Vite or Go dev server cold start. */
75
+ timeoutMs?: number;
76
+ /** Test seams; the real recipe functions by default. */
77
+ load?: (root: string) => RunRecipe;
78
+ start?: (recipe: RunRecipe, options: StartOptions) => AppHandle;
79
+ wait?: (url: string, options: WaitOptions) => Promise<Readiness>;
80
+ }
81
+
82
+ /** `opsee foreman debug serve <port>`: what the Foreman does before a Verifier, by hand. Starts
83
+ * the app from the cwd's Run Recipe on `port`, reports when the readiness URL answers, and keeps
84
+ * the app up until Ctrl-C. Exit 1 when the recipe is missing or the app never becomes ready. */
85
+ export async function runForemanDebugServe(deps: ForemanDebugServeDeps, port: number): Promise<number> {
86
+ const recipe = (deps.load ?? loadRunRecipe)(deps.root);
87
+ const timeoutMs = deps.timeoutMs ?? 120_000;
88
+ const app = (deps.start ?? startApp)(recipe, { port, cwd: deps.root, onOutput: (line) => deps.out(` | ${line}`) });
89
+ deps.out(`starting: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
90
+ deps.out(`waiting for ${app.readinessUrl} (up to ${timeoutMs}ms)`);
91
+ try {
92
+ const ready = await (deps.wait ?? waitForReady)(app.readinessUrl, { timeoutMs, signal: app.exitSignal });
93
+ deps.out(`ready: ${app.readinessUrl} answered after ${ready.elapsedMs}ms (${ready.attempts} attempts). Ctrl-C to stop.`);
94
+ } catch (error) {
95
+ deps.out(`not ready: ${error instanceof Error ? error.message : String(error)}`);
96
+ await app.stop();
97
+ return 1;
98
+ }
99
+ const outcome = await Promise.race([deps.untilStop.then(() => "stop" as const), app.exited.then((code) => ({ code }))]);
100
+ if (outcome === "stop") {
101
+ deps.out("stopping the app");
102
+ await app.stop();
103
+ return 0;
104
+ }
105
+ deps.out(`app exited with code ${outcome.code}`);
106
+ return outcome.code === 0 ? 0 : 1;
107
+ }
108
+
109
+ export interface ForemanDebugDeps {
110
+ tracker: Pick<TrackerAdapter, "listReadyTasks">;
111
+ out: (line: string) => void;
112
+ }
113
+
114
+ /** `opsee foreman debug ready <initiativeId>`: prints the Initiative's Ready Tasks exactly as the
115
+ * Foreman would dispatch them (server-derived first open slice, dispatch label applied). Read-only.
116
+ * Returns the process exit code. */
117
+ export async function runForemanDebugReady(deps: ForemanDebugDeps, initiativeId: number): Promise<number> {
118
+ const ready = await deps.tracker.listReadyTasks(initiativeId);
119
+ if (!ready.dispatchLabelExists) {
120
+ deps.out(
121
+ `Initiative ${initiativeId}: the project has no "${ready.dispatchLabel}" label, so no Task can be ready.`,
122
+ );
123
+ return 1;
124
+ }
125
+ deps.out(`Initiative ${initiativeId}: ${ready.tasks.length} ready (label "${ready.dispatchLabel}")`);
126
+ for (const task of ready.tasks) {
127
+ const column = task.column ? ` [${task.column.name}]` : "";
128
+ deps.out(` ${task.identifier} ${task.title}${column} (id ${task.id})`);
129
+ }
130
+ return 0;
131
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `opsee foreman plan <initiativeId>` (story 10): an attended planning session that starts already
3
+ * informed. The command picks the Account the way `foreman run` does, makes a Workspace on a
4
+ * planning branch of its own (`foreman/plan-<initiative>-<timestamp>`, from the default branch's
5
+ * remote head, through the guarded git like every other Workspace), checks the planning skill is
6
+ * in that worktree, assembles the Initiative's context (core/plan-context.ts: core idea, task
7
+ * tree, memory with the Completion Reports, pull requests) and opens the vendor's interactive
8
+ * session there, under the Account's environment, with that context and the instruction naming
9
+ * the skill as the first message. Then it waits for the human to leave and says where the
10
+ * worktree is.
11
+ *
12
+ * Attended only: nothing here launches or resumes a Worker turn, and the session is never resumed
13
+ * unattended. The Tasks the session files go through the MCP tools the skills already use; the
14
+ * prompt gives the Initiative id so they land there, with the four work-contract headings.
15
+ *
16
+ * Outside the guard (ADR-0003): the session's git is the human's own, with their credentials, in
17
+ * a worktree the guarded runner made but does not police; the prompt tells the session to commit
18
+ * to the planning branch and leave pushing, merging and pull requests to the human.
19
+ *
20
+ * The skills are the repository's own (`opsee init` writes them into `.claude/skills` and
21
+ * `.agents/skills`, OPS-261/OPS-264). This command never writes them: a worktree the human will
22
+ * commit from is theirs, and a skill written silently would show up as their change. A checkout
23
+ * without the skill is refused with the line that fixes it.
24
+ */
25
+ import type { ChildProcess } from "node:child_process";
26
+ import type { EventEmitter } from "node:events";
27
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { type Account } from "../foreman/account.js";
31
+ import type { AccountStore } from "../foreman/account-store.js";
32
+ import { assemblePlanContext, DEFAULT_MEMORY_LIMIT, DEFAULT_PLANNING_SKILL, planBranch, WORK_CONTRACT_HEADINGS, type PlanningSkill } from "../foreman/core/plan-context.js";
33
+ import { ForemanError, type RunDeps } from "../foreman/core/run.js";
34
+ import { guardedGit, runGit, WorkspaceManager, type GitRunner } from "../foreman/core/workspace.js";
35
+ import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
36
+ import type { Vendor } from "../foreman/vendor.js";
37
+ import type { InteractiveCommand, WorkerAdapter } from "../foreman/worker-adapter.js";
38
+ import { oneLine } from "../foreman/core/text.js";
39
+ import { errorMessage } from "../foreman/worker-process.js";
40
+ import { chooseAccount } from "./foreman.js";
41
+ import { runInteractiveCommand } from "./foreman-control.js";
42
+
43
+ /** Options of `opsee foreman plan`. */
44
+ export interface ForemanPlanArgs {
45
+ initiativeId: number;
46
+ account?: string;
47
+ skill: PlanningSkill;
48
+ /** `--memory <n>`: the newest this many memory entries in the context; `DEFAULT_MEMORY_LIMIT` unless set. */
49
+ memory?: number;
50
+ }
51
+
52
+ export interface ForemanPlanDeps {
53
+ tracker: TrackerAdapter;
54
+ store: AccountStore;
55
+ adapterFor: (account: Account) => WorkerAdapter;
56
+ /** The repository the session plans in: the checkout the command runs in. */
57
+ repoRoot: () => Promise<string | undefined>;
58
+ out: (line: string) => void;
59
+ /** Test seams; the real Workspace manager over the guarded git, and a real spawn, by default. */
60
+ workspacesFor?: (repoRoot: string, log: (line: string) => void, git: GitRunner) => RunDeps["workspaces"];
61
+ runInteractive?: (command: InteractiveCommand) => Promise<number>;
62
+ /** Whether `dir` holds the skill for the vendor (`skillPath`); the file system by default. */
63
+ hasSkill?: (dir: string, vendor: Vendor, skill: PlanningSkill) => boolean;
64
+ now?: () => Date;
65
+ /** Above this many bytes the prompt goes to a file the first message points at. */
66
+ promptArgLimit?: number;
67
+ /** Where that file is made; the OS temp dir by default. */
68
+ tmpDir?: string;
69
+ /** Where the signal handlers that remove that file go; this process by default. */
70
+ signals?: EventEmitter;
71
+ }
72
+
73
+ /** Where each vendor looks for the repository's skills (`opsee init` writes both, init/paths.ts). */
74
+ export const SKILL_DIR_BY_VENDOR: Readonly<Record<Vendor, string>> = {
75
+ claude: ".claude/skills",
76
+ codex: ".agents/skills",
77
+ };
78
+
79
+ /** `<dir>/.claude/skills/to-issues/SKILL.md`: the file that makes a skill available to a vendor. */
80
+ export function skillPath(dir: string, vendor: Vendor, skill: PlanningSkill): string {
81
+ return join(dir, SKILL_DIR_BY_VENDOR[vendor], skill, "SKILL.md");
82
+ }
83
+
84
+ /**
85
+ * The most bytes the prompt is passed as one argument. Linux caps a single argument at 128 KiB
86
+ * (MAX_ARG_STRLEN), macOS the whole argument list at 1 MiB (ARG_MAX), and the vendor's own
87
+ * options sit beside it; a context past this goes to a file, and the first message points at it.
88
+ */
89
+ export const PROMPT_ARG_LIMIT = 100_000;
90
+
91
+ /**
92
+ * The first message when the context is in a file: short, the only thing on the command line, and
93
+ * enough on its own to satisfy the session's brief (the skill, the Initiative, the headings), since
94
+ * a file tool that stops at a line count would otherwise leave the model without them. The file
95
+ * has its Instructions first for the same reason; the pointer says how long it is and to read to
96
+ * the end.
97
+ */
98
+ export function pointerPrompt(file: string, bytes: number, lines: number, skill: PlanningSkill, initiativeId: number): string {
99
+ const headings = WORK_CONTRACT_HEADINGS.map((h) => `## ${h}`).join(", ");
100
+ return [
101
+ `The planning context and your instructions for this session are in the file ${file}: ${bytes} bytes, ${lines} lines, the Instructions section first, then the Initiative's context. Read the whole file before doing anything else, to line ${lines}, in more than one read if your file tool returns only part of it (some stop at 2000 lines unless given an offset); then follow the Instructions.`,
102
+ `In short: run the /${skill} skill against Initiative ${initiativeId}; every Task filed from this session lands in Initiative ${initiativeId} through the Opsee MCP tools, with the work contract in its description as the four markdown headings ${headings}; propose before you publish.`,
103
+ ].join(" ");
104
+ }
105
+
106
+ /** The signals on which the context file is removed while the session runs. */
107
+ export const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
108
+
109
+ /**
110
+ * Removes `dir` when this process gets a signal while the session runs, since `finally` runs on
111
+ * none of them. SIGINT is the terminal's Ctrl-C, which the session gets from the terminal too:
112
+ * the directory goes and this process keeps waiting for the session to end. SIGTERM and SIGHUP
113
+ * (a `kill`, the terminal closing) remove it, go on to the session, and are then re-raised so
114
+ * this process dies of them as it would have. Returns the disposer that unregisters the handlers.
115
+ */
116
+ export function cleanupOnSignal(
117
+ dir: string,
118
+ child: () => Pick<ChildProcess, "kill"> | undefined,
119
+ options: { target?: EventEmitter; raise?: (signal: NodeJS.Signals) => void } = {},
120
+ ): () => void {
121
+ const target = options.target ?? process;
122
+ const raise = options.raise ?? ((signal: NodeJS.Signals) => process.kill(process.pid, signal));
123
+ const listeners: [NodeJS.Signals, () => void][] = [];
124
+ const dispose = () => {
125
+ for (const [signal, listener] of listeners) target.removeListener(signal, listener);
126
+ };
127
+ for (const signal of CLEANUP_SIGNALS) {
128
+ const listener = () => {
129
+ rmSync(dir, { recursive: true, force: true });
130
+ if (signal === "SIGINT") return;
131
+ dispose();
132
+ child()?.kill(signal);
133
+ raise(signal);
134
+ };
135
+ listeners.push([signal, listener]);
136
+ target.on(signal, listener);
137
+ }
138
+ return dispose;
139
+ }
140
+
141
+ /** `opsee foreman plan <initiativeId>` (story 10). Exit 0 once the human leaves the session. */
142
+ export async function runForemanPlan(deps: ForemanPlanDeps, args: ForemanPlanArgs): Promise<number> {
143
+ const { out } = deps;
144
+ const skill = args.skill ?? DEFAULT_PLANNING_SKILL;
145
+ const hasSkill = deps.hasSkill ?? ((dir, vendor, name) => existsSync(skillPath(dir, vendor, name)));
146
+ const account = chooseAccount(deps.store.load(), args.account);
147
+ const repoRoot = await deps.repoRoot();
148
+ if (!repoRoot) throw new ForemanError("Not inside a git repository; run foreman plan from the checkout the Initiative is about.");
149
+
150
+ // The checkout first, before any git or Tracker call: the Workspace is made from what is merged,
151
+ // so a skill missing here is missing there too, and the fix is the same.
152
+ if (!hasSkill(repoRoot, account.vendor, skill)) {
153
+ throw new ForemanError(
154
+ `The ${skill} skill is not in this repository at ${skillPath(repoRoot, account.vendor, skill)}, so a ${account.vendor} session could not run /${skill}. Run opsee init here, commit the skills it writes and merge them to the default branch (the Workspace is made from origin's), then run foreman plan again.`,
155
+ );
156
+ }
157
+
158
+ const context = await deps.tracker.getInitiativeContext(args.initiativeId);
159
+ const memoryLimit = args.memory ?? DEFAULT_MEMORY_LIMIT;
160
+ // The title is agent-writable and this is the terminal: one line of it.
161
+ out(`plan: Initiative ${context.initiative.id} "${oneLine(context.initiative.title)}" on Account "${account.name}" (${account.vendor}), skill /${skill}, memory ${memoryLimit}`);
162
+
163
+ const git = guardedGit(runGit, repoRoot);
164
+ const workspaces = (deps.workspacesFor ?? ((root, log, guarded) => new WorkspaceManager(root, { log, git: guarded })))(repoRoot, out, git);
165
+ const branch = planBranch(args.initiativeId, (deps.now ?? (() => new Date()))());
166
+ const workspace = await workspaces.create(branch);
167
+ // Both paths quoted: the line is meant to be pasted, and a workspaces root with a space in it is not rare.
168
+ const removal = `git -C "${repoRoot}" worktree remove "${workspace.path}" && git -C "${repoRoot}" branch -D ${workspace.branch}`;
169
+ out(`plan: Workspace ${workspace.path} on branch ${workspace.branch}${workspace.base ? ` from ${workspace.base}` : ""}`);
170
+ if (!hasSkill(workspace.path, account.vendor, skill)) {
171
+ throw new ForemanError(
172
+ `The ${skill} skill is in this checkout but not on the default branch's remote head, which the Workspace ${workspace.path} was made from (${skillPath(workspace.path, account.vendor, skill)} is missing). Merge the skills opsee init wrote, then run foreman plan again; to remove this Workspace: ${removal}`,
173
+ );
174
+ }
175
+
176
+ const assembled = assemblePlanContext({ context, skill, workspace, memoryLimit });
177
+ for (const line of assembled.summary) out(`context: ${line}`);
178
+
179
+ // The prompt as one argument when the OS allows it, else in a file of its own (never in the
180
+ // Workspace, whose contents are the human's) that the first message points at, laid out with
181
+ // the Instructions first.
182
+ let prompt = assembled.prompt;
183
+ let promptDir: string | undefined;
184
+ const promptBytes = Buffer.byteLength(prompt, "utf8");
185
+ if (promptBytes > (deps.promptArgLimit ?? PROMPT_ARG_LIMIT)) {
186
+ promptDir = mkdtempSync(join(deps.tmpDir ?? tmpdir(), "opsee-foreman-plan-"));
187
+ const file = join(promptDir, "planning-context.md");
188
+ const contents = assembled.fileContents;
189
+ const bytes = Buffer.byteLength(contents, "utf8");
190
+ const lines = contents.split("\n").length;
191
+ writeFileSync(file, contents, { mode: 0o600 });
192
+ out(`plan: the context is ${promptBytes} bytes, more than fits on the command line; written with the Instructions first to ${file} (${bytes} bytes, ${lines} lines), which the first message points at`);
193
+ prompt = pointerPrompt(file, bytes, lines, skill, args.initiativeId);
194
+ }
195
+
196
+ let code: number;
197
+ let child: ChildProcess | undefined;
198
+ const dispose = promptDir ? cleanupOnSignal(promptDir, () => child, { target: deps.signals }) : () => {};
199
+ try {
200
+ // Built before anything is said about opening: an Account whose key is not set fails here.
201
+ const command = deps.adapterFor(account).interactiveSession({ account, cwd: workspace.path, prompt });
202
+ out(`plan: opening ${command.command} in ${command.cwd} with the context as its first message (Account "${account.name}")`);
203
+ code = await (deps.runInteractive ?? ((c) => runInteractiveCommand(c, { onSpawn: (p) => (child = p) })))(command);
204
+ } catch (error) {
205
+ out(`plan: could not open the session (${errorMessage(error)}); the Workspace stays at ${workspace.path} on branch ${workspace.branch}. To remove it: ${removal}`);
206
+ throw error;
207
+ } finally {
208
+ dispose();
209
+ if (promptDir) rmSync(promptDir, { recursive: true, force: true });
210
+ }
211
+ out(`plan: session over (exit ${code}). The Workspace stays at ${workspace.path} on branch ${workspace.branch}; commit anything you want to keep from it, and remove it with: ${removal}`);
212
+ return 0;
213
+ }