@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
package/src/args.ts ADDED
@@ -0,0 +1,676 @@
1
+ import type { ForemanRunArgs } from "./commands/foreman.js";
2
+ import type { ForemanPlanArgs } from "./commands/foreman-plan.js";
3
+ import type { ForemanServiceArgs } from "./commands/foreman-service.js";
4
+ import type { ForemanLogsArgs, ForemanReviewArgs, ForemanStatusArgs } from "./commands/foreman-views.js";
5
+ import { DEFAULT_PLANNING_SKILL, isPlanningSkill, PLANNING_SKILLS } from "./foreman/core/plan-context.js";
6
+ import type { RecipeFlags } from "./init/run-recipe-config.js";
7
+
8
+ /** Raw options of `opsee foreman account add`; the account module validates them. */
9
+ export interface AccountAddArgs {
10
+ vendor?: string;
11
+ name?: string;
12
+ configDir?: string;
13
+ keyEnv?: string;
14
+ cap?: string;
15
+ }
16
+
17
+ /** Raw options of `opsee foreman account set <name>`: the scheduling limits of an Account that
18
+ * already exists, never its identity. Validated in the account module like the add form's. */
19
+ export interface AccountSetArgs {
20
+ name: string;
21
+ cap?: string;
22
+ maxTurns?: string;
23
+ stallTimeoutMs?: string;
24
+ }
25
+
26
+ /** Raw options of `opsee initiative list`. Validated where the command runs, like the account
27
+ * forms: the parser's job is to say what was typed, not whether it is usable. */
28
+ export interface InitiativeListArgs {
29
+ project?: string;
30
+ search?: string;
31
+ }
32
+
33
+ /** Raw options of `opsee initiative show <id>`. */
34
+ export interface InitiativeShowArgs {
35
+ initiativeId: number;
36
+ /** The whole work session — task tree, memory log, pull requests — rather than the details
37
+ * alone; the same split `opsee_get_initiative` and `opsee_get_initiative_context` draw. */
38
+ context: boolean;
39
+ memory?: string;
40
+ }
41
+
42
+ /** Raw options of `opsee initiative create`. */
43
+ export interface InitiativeCreateArgs {
44
+ title?: string;
45
+ project?: string;
46
+ summary?: string;
47
+ status?: string;
48
+ /** A file holding the core idea, or `-` for stdin: a markdown body does not fit in argv. */
49
+ coreIdeaFile?: string;
50
+ }
51
+
52
+ /** Raw options of `opsee initiative memory <id>`. */
53
+ export interface InitiativeMemoryArgs {
54
+ initiativeId: number;
55
+ kind?: string;
56
+ limit?: string;
57
+ }
58
+
59
+ /** Raw options of `opsee initiative note <id> <body>`. */
60
+ export interface InitiativeNoteArgs {
61
+ initiativeId: number;
62
+ body: string;
63
+ kind?: string;
64
+ }
65
+
66
+ /** Options of the hidden `opsee foreman debug turn`. */
67
+ export interface DebugTurnArgs {
68
+ account: string;
69
+ cwd: string;
70
+ prompt: string;
71
+ resume?: string;
72
+ maxTurns?: number;
73
+ stallTimeoutMs?: number;
74
+ /** Also print every raw stream line the vendor emitted, for re-recording fixtures. */
75
+ raw: boolean;
76
+ }
77
+
78
+ export type Command =
79
+ | { kind: "login" }
80
+ | { kind: "whoami" }
81
+ | { kind: "init"; projectKey?: string; recipe: RecipeFlags }
82
+ | { kind: "foreman-account-add"; args: AccountAddArgs }
83
+ | { kind: "foreman-account-list" }
84
+ /** Changes an Account's Slot cap and per-turn limits (story 27): raising a cap is always this. */
85
+ | { kind: "foreman-account-set"; args: AccountSetArgs }
86
+ | { kind: "foreman-account-remove"; name: string }
87
+ /** Lifts a quarantine and a pause by hand (OPS-288), so a re-authenticated Account comes back
88
+ * without the remove-and-add that would lose its cap and per-turn limits. */
89
+ | { kind: "foreman-account-resume"; name: string }
90
+ /** The Initiative surface (phase 1): thin commands over the same RPCs the MCP tools call, so the
91
+ * two surfaces cannot drift about what an Initiative looks like. */
92
+ | { kind: "initiative-list"; args: InitiativeListArgs }
93
+ | { kind: "initiative-show"; args: InitiativeShowArgs }
94
+ | { kind: "initiative-create"; args: InitiativeCreateArgs }
95
+ | { kind: "initiative-memory"; args: InitiativeMemoryArgs }
96
+ | { kind: "initiative-note"; args: InitiativeNoteArgs }
97
+ /** The daemon that idles until given work (story 13); a placeholder until the Process Table lands. */
98
+ | { kind: "foreman-up" }
99
+ /** A Run scoped to one Initiative, in the foreground until no Ready Task remains (story 14). */
100
+ | { kind: "foreman-run"; args: ForemanRunArgs }
101
+ /** An attended planning session on an Initiative, in a Workspace of its own (story 10). */
102
+ | { kind: "foreman-plan"; args: ForemanPlanArgs }
103
+ /** The daemon under launchd or systemd (story 16): write, remove or report its unit. */
104
+ | { kind: "foreman-service"; args: ForemanServiceArgs }
105
+ /** Attach (story 53): stop a Worker's unattended turn and resume its session in this terminal. */
106
+ | { kind: "foreman-attach"; taskId: number }
107
+ /** Release (story 54): hand an attached Worker back so its next turn runs unattended. */
108
+ | { kind: "foreman-release"; taskId: number }
109
+ /** Pause / resume (story 55): stop and restart new dispatch for the Initiative's Run. */
110
+ | { kind: "foreman-pause"; initiativeId: number }
111
+ | { kind: "foreman-resume"; initiativeId: number }
112
+ /** Cancel (story 56): stop a Worker and record the attempt as cancelled. */
113
+ | { kind: "foreman-cancel"; taskId: number }
114
+ /** The three read-only views (OPS-280); none of them touches a Worker or the Run Record. */
115
+ | { kind: "foreman-status"; args: ForemanStatusArgs }
116
+ | { kind: "foreman-logs"; args: ForemanLogsArgs }
117
+ | { kind: "foreman-review"; args: ForemanReviewArgs }
118
+ /** Hidden and read-only: lists an Initiative's Ready Tasks as the Tracker Adapter sees them. */
119
+ | { kind: "foreman-debug-ready"; initiativeId: number }
120
+ /** Hidden: starts the app from the cwd's Run Recipe on `port` and reports when it is ready. */
121
+ | { kind: "foreman-debug-serve"; port: number }
122
+ /** Hidden: runs one unattended Worker turn through the Worker Adapter and prints its report. */
123
+ | { kind: "foreman-debug-turn"; args: DebugTurnArgs }
124
+ | { kind: "help" }
125
+ | { kind: "usage-error"; message: string }
126
+ | { kind: "unknown"; input: string };
127
+
128
+ const ACCOUNT_ADD_OPTIONS: Record<string, keyof AccountAddArgs> = {
129
+ "--vendor": "vendor",
130
+ "--name": "name",
131
+ "--config-dir": "configDir",
132
+ "--key-env": "keyEnv",
133
+ "--cap": "cap",
134
+ };
135
+
136
+ function parseAccountAdd(argv: string[]): Command {
137
+ const args: AccountAddArgs = {};
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const token = argv[i];
140
+ const eq = token.indexOf("=");
141
+ const flag = eq === -1 ? token : token.slice(0, eq);
142
+ const field = ACCOUNT_ADD_OPTIONS[flag];
143
+ if (!field) {
144
+ // The flag only, never `token`: a value after `=` might be a pasted key.
145
+ return { kind: "usage-error", message: `Unknown option ${flag} for foreman account add` };
146
+ }
147
+ const value = eq === -1 ? argv[++i] : token.slice(eq + 1);
148
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) {
149
+ return { kind: "usage-error", message: `${flag} needs a value` };
150
+ }
151
+ args[field] = value;
152
+ }
153
+ return { kind: "foreman-account-add", args };
154
+ }
155
+
156
+ const ACCOUNT_SET_OPTIONS: Record<string, keyof Omit<AccountSetArgs, "name">> = {
157
+ "--cap": "cap",
158
+ "--max-turns": "maxTurns",
159
+ "--stall-timeout": "stallTimeoutMs",
160
+ };
161
+
162
+ const ACCOUNT_SET_USAGE = "foreman account set needs an Account name and at least one of --cap, --max-turns, --stall-timeout: foreman account set <name> --cap <n>";
163
+
164
+ function parseAccountSet(argv: string[]): Command {
165
+ const [name, ...rest] = argv;
166
+ if (!name || name.startsWith("--")) return { kind: "usage-error", message: ACCOUNT_SET_USAGE };
167
+ const args: AccountSetArgs = { name };
168
+ for (let i = 0; i < rest.length; i++) {
169
+ const token = rest[i];
170
+ const eq = token.indexOf("=");
171
+ const flag = eq === -1 ? token : token.slice(0, eq);
172
+ const field = ACCOUNT_SET_OPTIONS[flag];
173
+ if (!field) return { kind: "usage-error", message: `Unknown option ${flag} for foreman account set` };
174
+ const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
175
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) {
176
+ return { kind: "usage-error", message: `${flag} needs a value` };
177
+ }
178
+ args[field] = value;
179
+ }
180
+ if (rest.length === 0) return { kind: "usage-error", message: ACCOUNT_SET_USAGE };
181
+ return { kind: "foreman-account-set", args };
182
+ }
183
+
184
+ const DEBUG_TURN_VALUE_OPTIONS = ["--account", "--cwd", "--prompt", "--resume", "--max-turns", "--stall-timeout"] as const;
185
+
186
+ function parseDebugTurn(argv: string[]): Command {
187
+ const values: Partial<Record<(typeof DEBUG_TURN_VALUE_OPTIONS)[number], string>> = {};
188
+ let raw = false;
189
+ for (let i = 0; i < argv.length; i++) {
190
+ const token = argv[i];
191
+ if (token === "--raw") {
192
+ raw = true;
193
+ continue;
194
+ }
195
+ const eq = token.indexOf("=");
196
+ const flag = eq === -1 ? token : token.slice(0, eq);
197
+ if (!(DEBUG_TURN_VALUE_OPTIONS as readonly string[]).includes(flag)) {
198
+ return { kind: "usage-error", message: `Unknown option ${flag} for foreman debug turn` };
199
+ }
200
+ const value = eq === -1 ? argv[++i] : token.slice(eq + 1);
201
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) {
202
+ return { kind: "usage-error", message: `${flag} needs a value` };
203
+ }
204
+ values[flag as (typeof DEBUG_TURN_VALUE_OPTIONS)[number]] = value;
205
+ }
206
+ const account = values["--account"];
207
+ const cwd = values["--cwd"];
208
+ const prompt = values["--prompt"];
209
+ if (!account || !cwd || !prompt) {
210
+ return { kind: "usage-error", message: "foreman debug turn needs --account <name> --cwd <dir> --prompt <text> [--resume <session-id>] [--max-turns <n>] [--stall-timeout <ms>] [--raw]" };
211
+ }
212
+ const maxTurns = positiveInteger(values["--max-turns"]);
213
+ const stallTimeoutMs = positiveInteger(values["--stall-timeout"]);
214
+ if (maxTurns === null || stallTimeoutMs === null) {
215
+ return { kind: "usage-error", message: "--max-turns and --stall-timeout must be positive integers" };
216
+ }
217
+ return { kind: "foreman-debug-turn", args: { account, cwd, prompt, resume: values["--resume"], maxTurns, stallTimeoutMs, raw } };
218
+ }
219
+
220
+ const FOREMAN_SUBCOMMANDS = "up | run <initiativeId> | plan <initiativeId> | status | logs <task> | review [initiativeId] | attach <taskId> | release <taskId> | cancel <taskId> | pause <initiativeId> | resume <initiativeId> | service install | service uninstall | service status | account add | account list | account set | account remove | account resume";
221
+
222
+ /** The task-scoped controls (attach, release, cancel) take a Task id; pause and resume an
223
+ * Initiative id. All take exactly one positive integer. */
224
+ function parseForemanControl(kind: "foreman-attach" | "foreman-release" | "foreman-cancel" | "foreman-pause" | "foreman-resume", argv: string[]): Command {
225
+ const name = kind.slice("foreman-".length);
226
+ const what = kind === "foreman-pause" || kind === "foreman-resume" ? "an Initiative id" : "a Task id";
227
+ const id = positiveInteger(argv[0]);
228
+ if (!id || argv.length !== 1) return { kind: "usage-error", message: `foreman ${name} needs ${what}: foreman ${name} <${what === "a Task id" ? "taskId" : "initiativeId"}>` };
229
+ return kind === "foreman-pause" || kind === "foreman-resume" ? { kind, initiativeId: id } : { kind, taskId: id };
230
+ }
231
+
232
+ const FOREMAN_RUN_VALUE_OPTIONS = ["--account", "--task", "--max-turns", "--stall-timeout"] as const;
233
+ type ForemanRunValueOption = (typeof FOREMAN_RUN_VALUE_OPTIONS)[number];
234
+ const FOREMAN_RUN_USAGE = "foreman run needs an Initiative id: foreman run <initiativeId> [--account <name>] [--once] [--task <id>] [--max-turns <n>] [--stall-timeout <ms>]";
235
+
236
+ function positiveInteger(text: string | undefined): number | undefined | null {
237
+ if (text === undefined) return undefined;
238
+ const n = Number(text);
239
+ return Number.isInteger(n) && n > 0 ? n : null;
240
+ }
241
+
242
+ function parseForemanRun(argv: string[]): Command {
243
+ const [first, ...rest] = argv;
244
+ const initiativeId = positiveInteger(first);
245
+ if (!initiativeId) return { kind: "usage-error", message: FOREMAN_RUN_USAGE };
246
+ const values: Partial<Record<ForemanRunValueOption, string>> = {};
247
+ let once = false;
248
+ for (let i = 0; i < rest.length; i++) {
249
+ const token = rest[i];
250
+ if (token === "--once") {
251
+ once = true;
252
+ continue;
253
+ }
254
+ const eq = token.indexOf("=");
255
+ const flag = eq === -1 ? token : token.slice(0, eq);
256
+ if (!(FOREMAN_RUN_VALUE_OPTIONS as readonly string[]).includes(flag)) {
257
+ return { kind: "usage-error", message: `Unknown option ${flag} for foreman run` };
258
+ }
259
+ const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
260
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) {
261
+ return { kind: "usage-error", message: `${flag} needs a value` };
262
+ }
263
+ values[flag as ForemanRunValueOption] = value;
264
+ }
265
+ const taskId = positiveInteger(values["--task"]);
266
+ const maxTurns = positiveInteger(values["--max-turns"]);
267
+ const stallTimeoutMs = positiveInteger(values["--stall-timeout"]);
268
+ if (taskId === null || maxTurns === null || stallTimeoutMs === null) {
269
+ return { kind: "usage-error", message: "--task, --max-turns and --stall-timeout must be positive integers" };
270
+ }
271
+ return { kind: "foreman-run", args: { initiativeId, account: values["--account"], once, taskId, maxTurns, stallTimeoutMs } };
272
+ }
273
+
274
+ const FOREMAN_PLAN_VALUE_OPTIONS = ["--account", "--skill", "--memory"] as const;
275
+ type ForemanPlanValueOption = (typeof FOREMAN_PLAN_VALUE_OPTIONS)[number];
276
+ const FOREMAN_PLAN_USAGE = `foreman plan needs an Initiative id: foreman plan <initiativeId> [--account <name>] [--skill <${PLANNING_SKILLS.join("|")}>] [--memory <n>]`;
277
+
278
+ function parseForemanPlan(argv: string[]): Command {
279
+ const [first, ...rest] = argv;
280
+ const initiativeId = positiveInteger(first);
281
+ if (!initiativeId) return { kind: "usage-error", message: FOREMAN_PLAN_USAGE };
282
+ const values: Partial<Record<ForemanPlanValueOption, string>> = {};
283
+ for (let i = 0; i < rest.length; i++) {
284
+ const token = rest[i];
285
+ const eq = token.indexOf("=");
286
+ const flag = eq === -1 ? token : token.slice(0, eq);
287
+ if (!(FOREMAN_PLAN_VALUE_OPTIONS as readonly string[]).includes(flag)) {
288
+ return { kind: "usage-error", message: `Unknown option ${flag} for foreman plan` };
289
+ }
290
+ const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
291
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) {
292
+ return { kind: "usage-error", message: `${flag} needs a value` };
293
+ }
294
+ values[flag as ForemanPlanValueOption] = value;
295
+ }
296
+ const skill = values["--skill"] ?? DEFAULT_PLANNING_SKILL;
297
+ if (!isPlanningSkill(skill)) {
298
+ return { kind: "usage-error", message: `--skill must be one of ${PLANNING_SKILLS.join(", ")} (the planning skills opsee init writes), not "${skill}"` };
299
+ }
300
+ const memory = positiveInteger(values["--memory"]);
301
+ if (memory === null) return { kind: "usage-error", message: "--memory must be a positive integer (the newest that many memory entries go into the context)" };
302
+ return { kind: "foreman-plan", args: { initiativeId, account: values["--account"], skill, memory } };
303
+ }
304
+
305
+ const FOREMAN_SERVICE_USAGE = "foreman service needs one of: install [--start], uninstall [--stop], status";
306
+
307
+ function parseForemanService(action: string | undefined, rest: string[]): Command {
308
+ if (action !== "install" && action !== "uninstall" && action !== "status") return { kind: "usage-error", message: FOREMAN_SERVICE_USAGE };
309
+ const allowed = action === "install" ? "--start" : action === "uninstall" ? "--stop" : undefined;
310
+ let start = false;
311
+ let stop = false;
312
+ for (const token of rest) {
313
+ if (token !== allowed) return { kind: "usage-error", message: `Unknown option ${token} for foreman service ${action}` };
314
+ if (token === "--start") start = true;
315
+ if (token === "--stop") stop = true;
316
+ }
317
+ return { kind: "foreman-service", args: { action, start, stop } };
318
+ }
319
+
320
+ const FOREMAN_STATUS_USAGE = "foreman status takes no arguments but --watch [--every <seconds>]: foreman status [--watch] [--every <seconds>]";
321
+
322
+ /** `foreman status [--watch] [--every <seconds>]`. `--every` without `--watch` is a usage error
323
+ * rather than a silent no-op: the human asked for a repeating view and would get one reading. */
324
+ function parseForemanStatus(argv: string[]): Command {
325
+ let watch = false;
326
+ let watchMs: number | undefined;
327
+ for (let i = 0; i < argv.length; i++) {
328
+ const token = argv[i];
329
+ if (token === "--watch") {
330
+ watch = true;
331
+ continue;
332
+ }
333
+ if (token === "--every") {
334
+ const seconds = Number(argv[++i]);
335
+ if (!Number.isFinite(seconds) || seconds <= 0) return { kind: "usage-error", message: "foreman status --every needs a positive number of seconds" };
336
+ watchMs = Math.round(seconds * 1000);
337
+ continue;
338
+ }
339
+ return { kind: "usage-error", message: `Unknown option ${token} for foreman status` };
340
+ }
341
+ if (watchMs !== undefined && !watch) return { kind: "usage-error", message: FOREMAN_STATUS_USAGE };
342
+ return { kind: "foreman-status", args: { watch, watchMs } };
343
+ }
344
+
345
+ const FOREMAN_LOGS_USAGE = "foreman logs needs a Task: foreman logs <OPS-123 | taskId> [--lines <n>] [--no-follow]";
346
+
347
+ /** `foreman logs <task> [--lines <n>] [--no-follow]`. The Task is an identifier or a numeric id;
348
+ * which of the two it is, is the command's to work out, not this parser's. */
349
+ function parseForemanLogs(argv: string[]): Command {
350
+ let task: string | undefined;
351
+ let lines: number | undefined;
352
+ let noFollow = false;
353
+ for (let i = 0; i < argv.length; i++) {
354
+ const token = argv[i];
355
+ if (token === "--no-follow") {
356
+ noFollow = true;
357
+ continue;
358
+ }
359
+ if (token === "--lines") {
360
+ const n = Number(argv[++i]);
361
+ if (!Number.isInteger(n) || n <= 0) return { kind: "usage-error", message: "foreman logs --lines needs a positive whole number" };
362
+ lines = n;
363
+ continue;
364
+ }
365
+ if (token.startsWith("--")) return { kind: "usage-error", message: `Unknown option ${token} for foreman logs` };
366
+ if (task !== undefined) return { kind: "usage-error", message: FOREMAN_LOGS_USAGE };
367
+ task = token;
368
+ }
369
+ if (!task) return { kind: "usage-error", message: FOREMAN_LOGS_USAGE };
370
+ return { kind: "foreman-logs", args: { task, lines, noFollow } };
371
+ }
372
+
373
+ /** `foreman review [<initiativeId>]`; the id is optional because the Process Table usually names
374
+ * exactly one Initiative and the command falls back to it. */
375
+ function parseForemanReview(argv: string[]): Command {
376
+ if (argv.length === 0) return { kind: "foreman-review", args: {} };
377
+ const id = Number(argv[0]);
378
+ if (argv.length !== 1 || !Number.isInteger(id) || id <= 0) {
379
+ return { kind: "usage-error", message: "foreman review takes at most one Initiative id: foreman review [initiativeId]" };
380
+ }
381
+ return { kind: "foreman-review", args: { initiativeId: id } };
382
+ }
383
+
384
+ function parseForeman(argv: string[]): Command {
385
+ const [group, action, ...rest] = argv;
386
+ if (group === "up") {
387
+ return action === undefined ? { kind: "foreman-up" } : { kind: "usage-error", message: "foreman up takes no arguments" };
388
+ }
389
+ if (group === "run") return parseForemanRun(argv.slice(1));
390
+ if (group === "plan") return parseForemanPlan(argv.slice(1));
391
+ if (group === "service") return parseForemanService(action, rest);
392
+ if (group === "status") return parseForemanStatus(argv.slice(1));
393
+ if (group === "logs") return parseForemanLogs(argv.slice(1));
394
+ if (group === "review") return parseForemanReview(argv.slice(1));
395
+ if (group === "attach" || group === "release" || group === "cancel" || group === "pause" || group === "resume") {
396
+ return parseForemanControl(`foreman-${group}`, argv.slice(1));
397
+ }
398
+ if (group === "debug") {
399
+ if (action === "turn") return parseDebugTurn(rest);
400
+ // The other hidden forms take one positive integer: an Initiative id, or a port.
401
+ const number = Number(rest[0]);
402
+ if (action === "ready" && Number.isInteger(number) && number > 0) {
403
+ return { kind: "foreman-debug-ready", initiativeId: number };
404
+ }
405
+ if (action === "serve" && Number.isInteger(number) && number > 0 && number < 65536) {
406
+ return { kind: "foreman-debug-serve", port: number };
407
+ }
408
+ // Hidden command: a malformed form is reported as unknown, not documented in usage.
409
+ return { kind: "unknown", input: ["foreman", ...argv].join(" ") };
410
+ }
411
+ if (group !== "account") {
412
+ return { kind: "usage-error", message: `foreman needs a subcommand: ${FOREMAN_SUBCOMMANDS}` };
413
+ }
414
+ switch (action) {
415
+ case "add":
416
+ return parseAccountAdd(rest);
417
+ case "list":
418
+ return { kind: "foreman-account-list" };
419
+ case "set":
420
+ return parseAccountSet(rest);
421
+ case "remove":
422
+ return rest[0] ? { kind: "foreman-account-remove", name: rest[0] } : { kind: "usage-error", message: "foreman account remove needs an Account name" };
423
+ case "resume":
424
+ return rest[0] ? { kind: "foreman-account-resume", name: rest[0] } : { kind: "usage-error", message: "foreman account resume needs an Account name" };
425
+ default:
426
+ return { kind: "usage-error", message: "foreman account needs one of: add, list, set, remove, resume" };
427
+ }
428
+ }
429
+
430
+ const INITIATIVE_SUBCOMMANDS = "list | show <id> | create | memory <id> | note <id> <body>";
431
+
432
+ /** Flag tables per subcommand: the flag as typed, and the field it fills. Kept as data so a typo
433
+ * is refused by name rather than silently ignored, exactly as the account forms do it. */
434
+ const INITIATIVE_LIST_OPTIONS: Record<string, keyof InitiativeListArgs> = { "--project": "project", "--search": "search" };
435
+ const INITIATIVE_CREATE_OPTIONS: Record<string, keyof InitiativeCreateArgs> = {
436
+ "--title": "title",
437
+ "--project": "project",
438
+ "--summary": "summary",
439
+ "--status": "status",
440
+ "--core-idea-file": "coreIdeaFile",
441
+ };
442
+ const INITIATIVE_MEMORY_OPTIONS: Record<string, "kind" | "limit"> = { "--kind": "kind", "--limit": "limit" };
443
+
444
+ /**
445
+ * `--flag value` and `--flag=value` into a record, refusing an unknown flag by name and a flag with
446
+ * nothing after it. Returns the message rather than throwing, so every caller reports it the same
447
+ * way. `bare` names the valueless flags, which are set to "" and read as present.
448
+ */
449
+ function parseFlags<K extends string>(argv: string[], table: Record<string, K>, where: string, bare: readonly string[] = []): Partial<Record<K, string>> | string {
450
+ const out: Partial<Record<K, string>> = {};
451
+ for (let i = 0; i < argv.length; i++) {
452
+ const token = argv[i];
453
+ const eq = token.indexOf("=");
454
+ const flag = eq === -1 ? token : token.slice(0, eq);
455
+ if (bare.includes(flag)) {
456
+ if (eq !== -1) return `${flag} takes no value`;
457
+ out[table[flag]] = "";
458
+ continue;
459
+ }
460
+ const field = table[flag];
461
+ if (!field) return `Unknown option ${flag} for ${where}`;
462
+ const value = eq === -1 ? argv[++i] : token.slice(eq + 1);
463
+ // A flag followed by another flag is a value the user forgot, not a value of "--x".
464
+ if (value === undefined || (eq === -1 && value.startsWith("--"))) return `${flag} needs a value`;
465
+ out[field] = value;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ /** The `<id>` every Initiative subcommand but `list` and `create` takes first. */
471
+ function initiativeId(token: string | undefined, where: string): number | string {
472
+ if (token === undefined || !/^\d+$/.test(token)) return `${where} needs an Initiative id: opsee initiative ${where.replace("initiative ", "")} <initiativeId>`;
473
+ const id = Number(token);
474
+ return id >= 1 ? id : `${where} needs an Initiative id of 1 or more`;
475
+ }
476
+
477
+ function parseInitiative(argv: string[]): Command {
478
+ const [action, ...rest] = argv;
479
+ switch (action) {
480
+ case "list": {
481
+ const flags = parseFlags(rest, INITIATIVE_LIST_OPTIONS, "initiative list");
482
+ return typeof flags === "string" ? { kind: "usage-error", message: flags } : { kind: "initiative-list", args: flags };
483
+ }
484
+ case "show": {
485
+ const [id, ...flags] = rest;
486
+ const parsedId = initiativeId(id, "initiative show");
487
+ if (typeof parsedId === "string") return { kind: "usage-error", message: parsedId };
488
+ const parsed = parseFlags(flags, { "--context": "context", "--memory": "memory" } as const, "initiative show", ["--context"]);
489
+ if (typeof parsed === "string") return { kind: "usage-error", message: parsed };
490
+ return { kind: "initiative-show", args: { initiativeId: parsedId, context: parsed.context !== undefined, memory: parsed.memory } };
491
+ }
492
+ case "create": {
493
+ const flags = parseFlags(rest, INITIATIVE_CREATE_OPTIONS, "initiative create");
494
+ if (typeof flags === "string") return { kind: "usage-error", message: flags };
495
+ if (!flags.title) return { kind: "usage-error", message: "initiative create needs a title: opsee initiative create --title <title> [--project <key>] [--summary <text>] [--core-idea-file <path|->]" };
496
+ return { kind: "initiative-create", args: flags };
497
+ }
498
+ case "memory": {
499
+ const [id, ...flags] = rest;
500
+ const parsedId = initiativeId(id, "initiative memory");
501
+ if (typeof parsedId === "string") return { kind: "usage-error", message: parsedId };
502
+ const parsed = parseFlags(flags, INITIATIVE_MEMORY_OPTIONS, "initiative memory");
503
+ if (typeof parsed === "string") return { kind: "usage-error", message: parsed };
504
+ return { kind: "initiative-memory", args: { initiativeId: parsedId, ...parsed } };
505
+ }
506
+ case "note": {
507
+ const [id, ...restOfNote] = rest;
508
+ const parsedId = initiativeId(id, "initiative note");
509
+ if (typeof parsedId === "string") return { kind: "usage-error", message: parsedId };
510
+ // The body is the one positional after the id; flags may sit on either side of it, so it is
511
+ // whatever is left once the flags are taken out rather than "the next token".
512
+ const flagTokens: string[] = [];
513
+ const positionals: string[] = [];
514
+ for (let i = 0; i < restOfNote.length; i++) {
515
+ const token = restOfNote[i];
516
+ if (!token.startsWith("--")) {
517
+ positionals.push(token);
518
+ continue;
519
+ }
520
+ flagTokens.push(token);
521
+ if (token.indexOf("=") === -1 && restOfNote[i + 1] !== undefined && !restOfNote[i + 1].startsWith("--")) flagTokens.push(restOfNote[++i]);
522
+ }
523
+ const parsed = parseFlags(flagTokens, { "--kind": "kind" } as const, "initiative note");
524
+ if (typeof parsed === "string") return { kind: "usage-error", message: parsed };
525
+ if (positionals.length === 0) return { kind: "usage-error", message: 'initiative note needs the note text: opsee initiative note <initiativeId> "what happened" [--kind <kind>]' };
526
+ if (positionals.length > 1) return { kind: "usage-error", message: `initiative note takes one note; quote it if it has spaces (got ${positionals.length} arguments after the id)` };
527
+ return { kind: "initiative-note", args: { initiativeId: parsedId, body: positionals[0], kind: parsed.kind } };
528
+ }
529
+ default:
530
+ return { kind: "usage-error", message: `initiative needs a subcommand: ${INITIATIVE_SUBCOMMANDS}` };
531
+ }
532
+ }
533
+
534
+ export function parseCommand(argv: string[]): Command {
535
+ const [first, ...rest] = argv;
536
+ switch (first) {
537
+ case "login":
538
+ return { kind: "login" };
539
+ case "whoami":
540
+ return { kind: "whoami" };
541
+ case "init":
542
+ return parseInit(rest);
543
+ case "foreman":
544
+ return parseForeman(rest);
545
+ case "initiative":
546
+ return parseInitiative(rest);
547
+ case undefined:
548
+ case "help":
549
+ case "--help":
550
+ case "-h":
551
+ return { kind: "help" };
552
+ default:
553
+ return { kind: "unknown", input: first };
554
+ }
555
+ }
556
+
557
+ const INIT_RECIPE_OPTIONS: Record<string, keyof RecipeFlags> = {
558
+ "--start": "start",
559
+ "--readiness-url": "readinessUrl",
560
+ "--port-env": "portEnv",
561
+ "--typecheck": "typecheck",
562
+ };
563
+
564
+ function parseInit(rest: string[]): Command {
565
+ let projectKey: string | undefined;
566
+ const recipe: RecipeFlags = {};
567
+ for (let i = 0; i < rest.length; i++) {
568
+ const arg = rest[i];
569
+ const eq = arg.indexOf("=");
570
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
571
+ const recipeField = INIT_RECIPE_OPTIONS[flag];
572
+ if (arg === "--project" || arg === "-p") {
573
+ projectKey = rest[++i] ?? "";
574
+ } else if (arg.startsWith("--project=")) {
575
+ projectKey = arg.slice("--project=".length);
576
+ } else if (recipeField) {
577
+ const value = eq === -1 ? rest[++i] : arg.slice(eq + 1);
578
+ if (value === undefined || value === "") return { kind: "usage-error", message: `${flag} needs a value` };
579
+ recipe[recipeField] = value;
580
+ } else {
581
+ return { kind: "unknown", input: `init ${arg}` };
582
+ }
583
+ }
584
+ if (projectKey === "") return { kind: "unknown", input: "init --project (missing key)" };
585
+ return { kind: "init", projectKey, recipe };
586
+ }
587
+
588
+ export function usage(): string {
589
+ return [
590
+ "Usage: opsee <command>",
591
+ "",
592
+ "Commands:",
593
+ " opsee login Sign in through the browser; shared with the Opsee MCP server",
594
+ " opsee whoami Show the signed-in user and company",
595
+ " opsee init [--project <key>] Set up the current repo for Opsee-driven planning: planning",
596
+ " skills, the tracker doc, MCP registration, AGENTS.md pointer,",
597
+ " and the Foreman's Run Recipe in .opsee/config",
598
+ " [--start <cmd>] [--readiness-url <url>] [--port-env <VAR>] [--typecheck <cmd>]",
599
+ " Run Recipe values for a non-interactive run; {port} in a",
600
+ " command or URL is replaced with the Worker's port",
601
+ "",
602
+ "Foreman:",
603
+ " opsee foreman up Start the Foreman daemon: it Reconciles each tick and serves Runs queued by foreman run",
604
+ " opsee foreman run <initiativeId> [--account <name>] [--once] [--task <id>]",
605
+ " [--max-turns <n>] [--stall-timeout <ms>]",
606
+ " Run the Initiative's Ready Tasks one by one from this checkout:",
607
+ " a Workspace and branch per Task, one unattended Worker turn each,",
608
+ " Completion Reports into Initiative memory, moves on the board",
609
+ " opsee foreman plan <initiativeId> [--account <name>] [--skill <to-issues|wayfinder|to-spec>]",
610
+ " [--memory <n>]",
611
+ " Open an attended planning session on the Initiative: a Workspace on",
612
+ " a planning branch, the vendor's interactive session there with the",
613
+ " Initiative's context (core idea, task tree, the newest n memory",
614
+ " entries with the Completion Reports, pull requests) as its first",
615
+ " message, and the skill to run",
616
+ " opsee foreman service install [--start]",
617
+ " Write the launchd LaunchAgent (macOS) or systemd user unit (Linux)",
618
+ " that runs foreman up in this checkout at login/boot and restarts it",
619
+ " after a crash; prints the commands that load it, or runs them with --start",
620
+ " opsee foreman service uninstall [--stop]",
621
+ " Remove that unit (stop and unload it first with --stop)",
622
+ " opsee foreman service status Whether the unit is installed and a daemon is up",
623
+ " opsee foreman status [--watch] [--every <seconds>]",
624
+ " What is running on this machine, read fresh from the Process Table:",
625
+ " each Account's Slots (cap, used, reserved Verifier Slot, free), the",
626
+ " live Workers, and every Paused Account and paused Initiative",
627
+ " opsee foreman logs <OPS-123 | taskId> [--lines <n>] [--no-follow]",
628
+ " One Worker's transcript: streamed live while its turn is running,",
629
+ " the stored tail once it is over (the header says which)",
630
+ " opsee foreman review [<initiativeId>]",
631
+ " The morning summary of a Run from the Run Record: Hand-offs to look",
632
+ " at, Defects filed, Breakers hit, Proposed Learnings to accept, with",
633
+ " every Task named by its title",
634
+ " opsee foreman attach <taskId> Stop the Worker's unattended turn and resume its session here,",
635
+ " in its Workspace, as an attended turn (exit the vendor's session",
636
+ " and run release to hand it back)",
637
+ " opsee foreman release <taskId> Hand an attached Worker back: the next tick resumes it unattended",
638
+ " opsee foreman cancel <taskId> Stop the Worker and record the attempt as cancelled; the Task is",
639
+ " left blocked with a comment",
640
+ " opsee foreman pause <initiativeId>",
641
+ " opsee foreman resume <initiativeId>",
642
+ " Stop and restart new dispatch for the Initiative's Run; running",
643
+ " Workers finish either way, both are on the Run Record",
644
+ "",
645
+ "Foreman Accounts (stored by path or variable name only; the credential is never read):",
646
+ " opsee foreman account add --vendor <claude|codex> --config-dir <dir> [--name <n>] [--cap <n>]",
647
+ " opsee foreman account add --vendor <claude|codex> --key-env <VAR> [--name <n>] [--cap <n>]",
648
+ " opsee foreman account list",
649
+ " opsee foreman account set <name> [--cap <n>] [--max-turns <n>] [--stall-timeout <ms>]",
650
+ " The Account's Slots and per-turn limits. The cap is how many",
651
+ " Workers may run on it at once, one Slot of it reserved for",
652
+ " Verifiers; it defaults to 2 and is only ever raised here",
653
+ " opsee foreman account remove <name>",
654
+ "",
655
+ "Initiatives (the same RPCs the Opsee MCP tools call, printed the same way):",
656
+ " opsee initiative list [--project <key>] [--search <text>]",
657
+ " The project's Initiatives, newest first; --search ranks a",
658
+ " keyword against title and summary",
659
+ " opsee initiative show <initiativeId> [--context] [--memory <n>]",
660
+ " Title, status, summary and core idea. --context prints the whole",
661
+ " work session instead: task tree, memory log and pull requests",
662
+ " opsee initiative create --title <title> [--project <key>] [--summary <text>]",
663
+ " [--status <draft|active|paused|done|archived>] [--core-idea-file <path|->]",
664
+ " A new Initiative. The core idea is markdown read from a file, or",
665
+ " from stdin when the path is -",
666
+ " opsee initiative memory <initiativeId> [--kind <kind>] [--limit <n>]",
667
+ " The append-only memory log, newest first",
668
+ " opsee initiative note <initiativeId> \"what happened\" [--kind <kind>]",
669
+ " Append one entry to that log; --kind is decision, outcome,",
670
+ " learning, blocker or context (the default)",
671
+ "",
672
+ "Environment: OPSEE_API_URL, OPSEE_APP_URL, OPSEE_MCP_URL, OPSEE_CREDENTIALS_PATH, OPSEE_API_TOKEN,",
673
+ " OPSEE_FOREMAN_ACCOUNTS_PATH, OPSEE_FOREMAN_WORKSPACES_PATH, OPSEE_FOREMAN_LOCAL_DIR",
674
+ " (or OPSEE_FOREMAN_PROCESS_TABLE_PATH, OPSEE_FOREMAN_TRANSCRIPTS_PATH, OPSEE_FOREMAN_PID_PATH)",
675
+ ].join("\n");
676
+ }