@opsee/cli 0.11.11 → 0.11.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,15 +102,33 @@ environment, so an app that reads the variable needs no placeholder at all. Vite
102
102
  kind: it ignores `PORT` and takes `--port`, and `--strictPort` makes it fail rather than drift to
103
103
  the next free port, which is the silent-corruption hazard AGENTS.md describes for two dev servers.
104
104
 
105
- `opsee init` writes the block once. `start` defaults to `commands.dev` (then `commands.start`),
106
- `gates.test` and `gates.lint` to `commands.test` and `commands.lint`, the readiness URL to
107
- `http://localhost:{port}/` and the variable to `PORT`; `typecheck` has no analyzer equivalent and
108
- is asked for or left out. On a terminal the four are prompted with those defaults; otherwise the
109
- defaults and the flags `--start`, `--readiness-url`, `--port-env`, `--typecheck` stand, and a repo
110
- where nothing says how to start the app gets a `skipped` line instead of a recipe. An existing
111
- `foreman` block, the user's or the analyzer's, is never rewritten: a re-run reports `unchanged`
112
- whatever the flags say, and a block that cannot serve as a recipe (no `start`, no
113
- `readiness_url`) is reported and kept. The YAML is spliced textually, the block appended in the
105
+ `opsee init` writes the block once. `gates.test` and `gates.lint` default to `commands.test` and
106
+ `commands.lint`; `start` is written only when `commands.dev` (then `commands.start`) or `--start`
107
+ already says how to serve the app, and the readiness URL and the port variable travel with it.
108
+ `typecheck` has no analyzer equivalent, so it is the one value a terminal is asked for.
109
+
110
+ **It never asks how to start the app.** That question has no answer at setup time in a repository
111
+ with several programs: "the app" means whichever one a Task's Verification happens to exercise, and
112
+ no Task exists yet. A block with Gates and no `start` is a complete recipe the Gates are the whole
113
+ Verdict for most repositories — and the Verifier says so when a Task does ask for a browser round.
114
+ The flags `--start`, `--readiness-url` and `--port-env` still write one for anyone who wants it
115
+ pinned.
116
+
117
+ When a Task's Verification asks for a browser round and the recipe has no `start`, the Foreman runs
118
+ a **Start turn** before giving up: one short unattended turn in the Task's Workspace
119
+ (`core/start-discovery.ts`), given that Task's Verification section, which answers with the command
120
+ that serves the program the Verification exercises. That is the whole reason `opsee init` can stop
121
+ asking — in a repository with several programs the answer depends on the Task, and a Task exists by
122
+ then. The answer must take the leased port (`{port}` or the port variable) or it is refused: a
123
+ command that serves on its own default is the failure that looks like success, because the readiness
124
+ URL answers from a server the Foreman never started. It is agent-authored, so it runs under the
125
+ Gates' filtered environment like every other command from the repository, and it lands verbatim on
126
+ the Run Record as the app's command.
127
+
128
+ `start` without `readiness_url` is still refused: that is half a recipe, which would serve the app
129
+ and never learn it came up. An existing `foreman` block, the user's or the analyzer's, is never
130
+ rewritten: a re-run reports `unchanged` whatever the flags say, and a block whose `verify` is not a
131
+ known word is reported and kept. The YAML is spliced textually, the block appended in the
114
132
  file's own indentation with no blank line before it, because a parsed-and-reserialised document
115
133
  would lose the analyzer's header comments; the JSON copy is re-serialised in its own indentation
116
134
  with `foreman` after the analyzer's members. The two are only ever both written when both exist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/cli",
3
- "version": "0.11.11",
3
+ "version": "0.11.13",
4
4
  "description": "Opsee CLI — the opsee binary: login, whoami, and the home of the Foreman",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "@bufbuild/protobuf": "^2.14.0",
18
18
  "@connectrpc/connect": "^2.1.2",
19
19
  "@connectrpc/connect-node": "^2.1.2",
20
- "@opsee/mcp-server": "0.11.11",
20
+ "@opsee/mcp-server": "0.11.13",
21
21
  "tsx": "^4.23.12"
22
22
  },
23
23
  "devDependencies": {
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
2
2
  import type { DebugTurnArgs } from "../args.js";
3
3
  import { AccountError, type Account } from "../foreman/account.js";
4
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";
5
+ import { loadRunRecipe, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
6
6
  import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
7
7
  import type { TurnRequest, WorkerAdapter } from "../foreman/worker-adapter.js";
8
8
 
@@ -75,7 +75,7 @@ export interface ForemanDebugServeDeps {
75
75
  timeoutMs?: number;
76
76
  /** Test seams; the real recipe functions by default. */
77
77
  load?: (root: string) => RunRecipe;
78
- start?: (recipe: RunRecipe, options: StartOptions) => AppHandle;
78
+ start?: (recipe: StartableRecipe, options: StartOptions) => AppHandle;
79
79
  wait?: (url: string, options: WaitOptions) => Promise<Readiness>;
80
80
  }
81
81
 
@@ -84,9 +84,16 @@ export interface ForemanDebugServeDeps {
84
84
  * the app up until Ctrl-C. Exit 1 when the recipe is missing or the app never becomes ready. */
85
85
  export async function runForemanDebugServe(deps: ForemanDebugServeDeps, port: number): Promise<number> {
86
86
  const recipe = (deps.load ?? loadRunRecipe)(deps.root);
87
+ // This command exists to serve the app, so a recipe that cannot is the whole failure, said here
88
+ // rather than as a spawn error from a command that is the empty string.
89
+ const servable = startable(recipe);
90
+ if (!servable) {
91
+ deps.out(`.opsee/config: the foreman block has no start command, so there is no app to serve. Add foreman.start, or let the Verifier work it out.`);
92
+ return 1;
93
+ }
87
94
  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}` : ""})`);
95
+ const app = (deps.start ?? startApp)(servable, { port, cwd: deps.root, onOutput: (line) => deps.out(` | ${line}`) });
96
+ deps.out(`starting: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
90
97
  deps.out(`waiting for ${app.readinessUrl} (up to ${timeoutMs}ms)`);
91
98
  try {
92
99
  const ready = await (deps.wait ?? waitForReady)(app.readinessUrl, { timeoutMs, signal: app.exitSignal });
@@ -15,7 +15,7 @@ import { pickProject, type ProjectChoice } from "../init/project.js";
15
15
  import { planRetire, RETIRED_SKILLS } from "../init/retire.js";
16
16
  import { listSkillFiles, skillNames } from "../init/skills.js";
17
17
  import { findReadyLabel, READY_LABEL, renderTrackerDoc, type Named, type TrackerDocData } from "../init/tracker-doc.js";
18
- import { existingRecipe, inferRecipe, mergeRecipeJson, mergeRecipeYaml, NO_START_COMMAND, type RecipeDefaults, type RecipeFlags } from "../init/run-recipe-config.js";
18
+ import { existingRecipe, inferRecipe, mergeRecipeJson, mergeRecipeYaml, type RecipeDefaults, type RecipeFlags } from "../init/run-recipe-config.js";
19
19
  import { OPSEE_CONFIG_JSON, OPSEE_CONFIG_YAML, readOpseeConfigFiles } from "../opsee-config.js";
20
20
  import type { RunRecipe } from "../foreman/run-recipe.js";
21
21
  import { NOT_LOGGED_IN } from "./whoami.js";
@@ -47,7 +47,7 @@ export interface InitDeps extends Pick<CommandDeps, "isAuthenticated" | "out"> {
47
47
  recipeFlags: RecipeFlags;
48
48
  /** Asks for the Run Recipe with inferred defaults; null when there is no start command to
49
49
  * write. Only called when `.opsee/config` has no `foreman` block yet. */
50
- askRecipe: (defaults: RecipeDefaults) => Promise<RunRecipe | null>;
50
+ askRecipe: (defaults: RecipeDefaults) => Promise<RunRecipe>;
51
51
  }
52
52
 
53
53
  export const CODEX_TRUST_HINT =
@@ -102,12 +102,8 @@ export async function runInit(deps: InitDeps): Promise<number> {
102
102
  // one of the two files is copied into the other, so they stay in step without a prompt.
103
103
  const config = readOpseeConfigFiles(deps.root);
104
104
  const answers = existingRecipe(config) ?? (await deps.askRecipe(inferRecipe(config, deps.recipeFlags)));
105
- if (answers === null) {
106
- deps.out(`skipped ${OPSEE_CONFIG_YAML} ${NO_START_COMMAND}`);
107
- } else {
108
- report.apply(OPSEE_CONFIG_YAML, mergeRecipeYaml(config.yaml, answers));
109
- if (config.json !== null || config.yaml === null) report.apply(OPSEE_CONFIG_JSON, mergeRecipeJson(config.json, answers));
110
- }
105
+ report.apply(OPSEE_CONFIG_YAML, mergeRecipeYaml(config.yaml, answers));
106
+ if (config.json !== null || config.yaml === null) report.apply(OPSEE_CONFIG_JSON, mergeRecipeJson(config.json, answers));
111
107
 
112
108
  deps.out("");
113
109
  deps.out(`Project ${project.name} (${project.key}, id ${project.id}); skills ${skillNames(skills).map((s) => `/${s}`).join(", ")}.`);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Driving one short unattended turn to its terminal event.
3
+ *
4
+ * The Foreman runs a few of these beside the implementer turn: the Triage turn that drafts a
5
+ * missing Verification section, and the Start turn that works out how to serve the app for a
6
+ * browser round. They differ only in their prompt and their log label — the launch, the event
7
+ * drain, the rate-limit capture and the output tail are the same, and were the same code twice
8
+ * before this module existed.
9
+ */
10
+ import type { Account } from "../account.js";
11
+ import type { AdapterEvent, WorkerAdapter } from "../worker-adapter.js";
12
+ import { tail } from "./install.js";
13
+
14
+ export interface ShortTurnRequest {
15
+ worker: WorkerAdapter;
16
+ account: Account;
17
+ /** Where the turn runs: the Task's Workspace, so the Worker reads the real repository. */
18
+ cwd: string;
19
+ prompt: string;
20
+ /** Prefixes every log line, so a reader tells this turn from the implementer's. */
21
+ label: string;
22
+ maxTurns: number;
23
+ stallTimeoutMs?: number;
24
+ log: (line: string) => void;
25
+ now: () => number;
26
+ }
27
+
28
+ export interface DrainedTurn {
29
+ terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
30
+ durationMs: number;
31
+ outputTail: string;
32
+ /** The vendor's rate-limit report, when one came (story 30). A short turn is a turn on the
33
+ * Account like any other, so a limit it hit Pauses the Account; the terminal event alone does not
34
+ * carry the reset the vendor named, which is what decides how long. */
35
+ rateLimit?: { resetAt?: string; message: string };
36
+ }
37
+
38
+ /** Runs one short turn to its terminal event, logging progress the way the dispatch loop does. */
39
+ export async function driveShortTurn(req: ShortTurnRequest): Promise<DrainedTurn> {
40
+ const started = req.now();
41
+ const handle = req.worker.launch({
42
+ account: req.account,
43
+ cwd: req.cwd,
44
+ prompt: req.prompt,
45
+ maxTurns: req.maxTurns,
46
+ stallTimeoutMs: req.stallTimeoutMs,
47
+ });
48
+ const output: string[] = [];
49
+ let terminal: DrainedTurn["terminal"] | undefined;
50
+ let rateLimit: DrainedTurn["rateLimit"];
51
+ for await (const event of handle.events) {
52
+ switch (event.type) {
53
+ case "started":
54
+ req.log(`${req.label}: session ${event.sessionId}`);
55
+ break;
56
+ case "output":
57
+ for (const line of event.text.split("\n")) {
58
+ output.push(line);
59
+ req.log(` | ${line}`);
60
+ }
61
+ break;
62
+ case "tool":
63
+ req.log(` tool ${event.name}`);
64
+ break;
65
+ case "rate_limited":
66
+ rateLimit = { resetAt: event.resetAt, message: event.message };
67
+ req.log(`${req.label}: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
68
+ break;
69
+ case "stalled":
70
+ req.log(`${req.label}: stalled, no output for ${event.silentMs}ms`);
71
+ break;
72
+ case "completed":
73
+ case "failed":
74
+ terminal = event;
75
+ break;
76
+ }
77
+ }
78
+ if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
79
+ return { terminal, durationMs: req.now() - started, outputTail: tail(output.join("\n")), rateLimit };
80
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The Start turn: how the Foreman learns to serve the app when the Run Recipe does not say.
3
+ *
4
+ * `opsee init` no longer asks for a start command, because at setup time there is no Task and
5
+ * therefore no answer to "which of these programs?" in a repository that holds several. The
6
+ * question does have an answer later: a Task's Verification section describes a user journey, the
7
+ * Verifier needs that Task's app on a port, and the Workspace has the repository in it. So the
8
+ * Foreman asks then, in the Workspace, the way it asks for a missing Verification section in a
9
+ * Triage turn.
10
+ *
11
+ * What comes back is a command the Foreman then runs. It runs under the Gates' filtered
12
+ * environment (core/gates.ts `gateEnv`), which holds no credential of the Foreman's, and it lands
13
+ * verbatim on the Run Record as the app's command — so a reader can see what was run and who
14
+ * wrote it. Treat the answer as data, never as instructions.
15
+ */
16
+ import type { Account } from "../account.js";
17
+ import type { StartableRecipe } from "../run-recipe.js";
18
+ import type { TrackerTask } from "../tracker-adapter.js";
19
+ import type { WorkerAdapter } from "../worker-adapter.js";
20
+ import { driveShortTurn } from "./short-turn.js";
21
+
22
+ /** Reading a package manifest and naming one command is a handful of agentic turns, not a
23
+ * Worker's budget; the Run's own `maxTurns` lowers this, never raises it. */
24
+ export const START_DISCOVERY_MAX_TURNS = 6;
25
+
26
+ /** What a start command must contain to be usable: the Foreman leases a port per Task and the app
27
+ * has to take it. A command that ignores the port is the failure that looks like success — the app
28
+ * comes up on its own default, the readiness URL answers from a server nobody asked for, and two
29
+ * Tasks verified at once quietly share it. Vite is the common case: it ignores `PORT` and needs
30
+ * `--port {port} --strictPort`. */
31
+ export function honoursPort(command: string, portEnv: string): boolean {
32
+ return command.includes("{port}") || command.includes(`$${portEnv}`) || command.includes(`\${${portEnv}}`);
33
+ }
34
+
35
+ export function startDiscoveryPrompt(task: Pick<TrackerTask, "identifier" | "title">, verification: string, workspace: { path: string }, portEnv: string): string {
36
+ return [
37
+ `You are a Worker run unattended by the Foreman for a Start turn on Task ${task.identifier}: ${task.title}.`,
38
+ "",
39
+ "A Verifier is about to prove this Task from the outside, in a browser. It needs the app running on a port the",
40
+ "Foreman leases. Nothing in this repository's Opsee config says how to start it. Work that out and report it.",
41
+ `Do not implement the Task and do not change any file: your working directory ${workspace.path} is the repository, read it.`,
42
+ "",
43
+ "## The Verification the Verifier will follow",
44
+ "",
45
+ verification,
46
+ "",
47
+ "## What to find",
48
+ "",
49
+ "The one program this Verification exercises — in a repository with several, the one whose pages it names —",
50
+ "and the command that serves it from the repository root, ready for a browser.",
51
+ "",
52
+ "## Rules for the command",
53
+ "",
54
+ `- It MUST take the port. Write the placeholder {port} where the port goes, or use $${portEnv}, which is set for it.`,
55
+ " A command that ignores the port is worse than none: the app comes up on its own default and the Verifier",
56
+ " drives a server it did not start.",
57
+ "- If the tool has a strict-port option, use it. Vite drifts to the next free port without --strictPort.",
58
+ "- It runs from the repository root, so include any directory change it needs.",
59
+ "- It serves; it does not build and exit.",
60
+ "",
61
+ "## Finishing",
62
+ "",
63
+ "End the turn with a Completion Report: outcome `done`, and the summary a single JSON object, nothing else:",
64
+ '{"start": "<command>", "readinessUrl": "<url that answers once it is up, with {port}>", "portEnv": "<variable name>"}',
65
+ "Report `blocked`, with the reason as a blocker, if this repository has no app a browser could drive.",
66
+ ].join("\n");
67
+ }
68
+
69
+ export interface StartAnswer {
70
+ recipe?: Pick<StartableRecipe, "start" | "readinessUrl" | "portEnv">;
71
+ /** Why the answer could not be used, for the log and the skip reason. */
72
+ reason?: string;
73
+ }
74
+
75
+ /**
76
+ * Reads the turn's summary back.
77
+ *
78
+ * Strict on purpose: this produces a shell command the Foreman executes, so a half-understood
79
+ * answer is refused rather than repaired. The port rule is the one that matters — see
80
+ * `honoursPort` for why a command that ignores it fails in the shape hardest to notice.
81
+ */
82
+ export function parseStartAnswer(summary: string, fallbackPortEnv: string): StartAnswer {
83
+ const text = summary.trim();
84
+ const start = text.indexOf("{");
85
+ const end = text.lastIndexOf("}");
86
+ if (start === -1 || end <= start) return { reason: "the Start turn answered with no JSON object" };
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(text.slice(start, end + 1));
90
+ } catch (error) {
91
+ return { reason: `the Start turn's answer is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
92
+ }
93
+ if (!parsed || typeof parsed !== "object") return { reason: "the Start turn's answer is not a JSON object" };
94
+ const block = parsed as Record<string, unknown>;
95
+ const str = (value: unknown) => (typeof value === "string" && value.trim() !== "" ? value.trim() : undefined);
96
+ const startCommand = str(block.start);
97
+ const readinessUrl = str(block.readinessUrl) ?? str(block.readiness_url);
98
+ const portEnv = str(block.portEnv) ?? str(block.port_env) ?? fallbackPortEnv;
99
+ if (!startCommand) return { reason: "the Start turn's answer has no start command" };
100
+ if (!readinessUrl) return { reason: "the Start turn's answer has no readiness URL" };
101
+ if (!honoursPort(startCommand, portEnv)) {
102
+ return { reason: `the Start turn's command ignores the port, so it would serve on its own: ${startCommand}` };
103
+ }
104
+ return { recipe: { start: startCommand, readinessUrl, portEnv } };
105
+ }
106
+
107
+ export interface StartDiscoveryRequest {
108
+ worker: WorkerAdapter;
109
+ account: Account;
110
+ task: Pick<TrackerTask, "identifier" | "title">;
111
+ verification: string;
112
+ workspace: { path: string };
113
+ portEnv: string;
114
+ maxTurns?: number;
115
+ stallTimeoutMs?: number;
116
+ log: (line: string) => void;
117
+ now: () => number;
118
+ }
119
+
120
+ /**
121
+ * Runs one Start turn and reads its answer. Never throws: a Start turn that fails is a browser
122
+ * round the Foreman cannot have, which is a skip, not an error.
123
+ *
124
+ * A rate limit here is logged and not acted on, which is the Verifier's own behaviour beside it:
125
+ * `VerifyOutcome` carries no way to report one, so run.ts cannot Pause the Account on a turn taken
126
+ * inside verification. A Triage turn does Pause, because it runs in run.ts where the Account is.
127
+ * Worth closing for both turns at once rather than for this one alone, and it is a spend of the
128
+ * cap either way rather than a wrong Verdict.
129
+ */
130
+ export async function discoverStart(req: StartDiscoveryRequest): Promise<StartAnswer> {
131
+ const maxTurns = Math.min(START_DISCOVERY_MAX_TURNS, req.maxTurns ?? START_DISCOVERY_MAX_TURNS);
132
+ req.log(`start: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path}, working out how to serve the app (at most ${maxTurns} turns)`);
133
+ let drained;
134
+ try {
135
+ drained = await driveShortTurn({
136
+ worker: req.worker,
137
+ account: req.account,
138
+ cwd: req.workspace.path,
139
+ prompt: startDiscoveryPrompt(req.task, req.verification, req.workspace, req.portEnv),
140
+ label: "start",
141
+ maxTurns,
142
+ stallTimeoutMs: req.stallTimeoutMs,
143
+ log: req.log,
144
+ now: req.now,
145
+ });
146
+ } catch (error) {
147
+ return { reason: `the Start turn failed: ${error instanceof Error ? error.message : String(error)}` };
148
+ }
149
+ if (drained.terminal.type !== "completed") {
150
+ return { reason: `the Start turn did not finish: ${drained.terminal.type === "failed" ? drained.terminal.reason : "no report"}` };
151
+ }
152
+ const summary = drained.terminal.report?.summary;
153
+ if (!summary) return { reason: "the Start turn finished without a Completion Report" };
154
+ const answer = parseStartAnswer(summary, req.portEnv);
155
+ if (answer.recipe) req.log(`start: ${req.task.identifier} will be served with: ${answer.recipe.start} (agent-authored, read as data)`);
156
+ else req.log(`start: ${req.task.identifier} ${answer.reason}`);
157
+ return answer;
158
+ }
@@ -10,9 +10,9 @@
10
10
  * drains the turn; run.ts records the outcome, since the Run Record write is the commit (ADR-0009).
11
11
  */
12
12
  import type { Account } from "../account.js";
13
+ import { driveShortTurn, type DrainedTurn } from "./short-turn.js";
13
14
  import type { TrackerTask } from "../tracker-adapter.js";
14
- import type { AdapterEvent, WorkerAdapter } from "../worker-adapter.js";
15
- import { tail } from "./install.js";
15
+ import type { WorkerAdapter } from "../worker-adapter.js";
16
16
  import type { WorkContract } from "./work-contract.js";
17
17
 
18
18
  /** Reading a few files and writing one section needs a handful of agentic turns, not a Worker's
@@ -81,58 +81,21 @@ export interface TriageTurnRequest {
81
81
  now: () => number;
82
82
  }
83
83
 
84
- export interface DrainedTurn {
85
- terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
86
- durationMs: number;
87
- outputTail: string;
88
- /** The vendor's rate-limit report, when one came (story 30). A Triage turn is a turn on the
89
- * Account like any other, so a limit it hit Pauses the Account; the terminal event alone does not
90
- * carry the reset the vendor named, which is what decides how long. */
91
- rateLimit?: { resetAt?: string; message: string };
92
- }
84
+ export type { DrainedTurn };
93
85
 
94
- /** Runs one Triage turn to its terminal event, logging progress the way the dispatch loop does. */
86
+ /** Runs one Triage turn to its terminal event. */
95
87
  export async function driveTriageTurn(req: TriageTurnRequest): Promise<DrainedTurn> {
96
88
  const maxTurns = Math.min(TRIAGE_MAX_TURNS, req.maxTurns ?? TRIAGE_MAX_TURNS);
97
- const started = req.now();
98
- const handle = req.worker.launch({
89
+ req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
90
+ return driveShortTurn({
91
+ worker: req.worker,
99
92
  account: req.account,
100
93
  cwd: req.workspace.path,
101
94
  prompt: triagePrompt(req.task, req.contract, req.workspace),
95
+ label: "triage",
102
96
  maxTurns,
103
97
  stallTimeoutMs: req.stallTimeoutMs,
98
+ log: req.log,
99
+ now: req.now,
104
100
  });
105
- req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
106
- const output: string[] = [];
107
- let terminal: DrainedTurn["terminal"] | undefined;
108
- let rateLimit: DrainedTurn["rateLimit"];
109
- for await (const event of handle.events) {
110
- switch (event.type) {
111
- case "started":
112
- req.log(`triage: session ${event.sessionId}`);
113
- break;
114
- case "output":
115
- for (const line of event.text.split("\n")) {
116
- output.push(line);
117
- req.log(` | ${line}`);
118
- }
119
- break;
120
- case "tool":
121
- req.log(` tool ${event.name}`);
122
- break;
123
- case "rate_limited":
124
- rateLimit = { resetAt: event.resetAt, message: event.message };
125
- req.log(`triage: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
126
- break;
127
- case "stalled":
128
- req.log(`triage: stalled, no output for ${event.silentMs}ms`);
129
- break;
130
- case "completed":
131
- case "failed":
132
- terminal = event;
133
- break;
134
- }
135
- }
136
- if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
137
- return { terminal, durationMs: req.now() - started, outputTail: tail(output.join("\n")), rateLimit };
138
101
  }
@@ -46,7 +46,8 @@ import { RunEventInputSchema, type RunEvent, type RunEventInput, type RunVerific
46
46
  import { parseOpseeJson, readYamlBlock, type OpseeConfigFiles } from "../../opsee-config.js";
47
47
  import type { Account } from "../account.js";
48
48
  import { foremanLocalDir } from "../local-dir.js";
49
- import { RECIPE_KEY, recipeFromConfig, startApp, waitForReady, type RunRecipe, type VerifyMode } from "../run-recipe.js";
49
+ import { RECIPE_KEY, recipeFromConfig, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type VerifyMode } from "../run-recipe.js";
50
+ import { discoverStart, type StartAnswer } from "./start-discovery.js";
50
51
  import type { TrackerTask } from "../tracker-adapter.js";
51
52
  import { parseVerdict, VERDICT_CONTRACT, type Defect, type Verdict } from "../verdict.js";
52
53
  import type { AdapterEvent, McpServerSpec, WorkerAdapter } from "../worker-adapter.js";
@@ -423,6 +424,10 @@ export interface VerifierOptions {
423
424
  * default). */
424
425
  mcpFor?: (outputDir: string, allowedOrigins: readonly string[]) => McpServerSpec;
425
426
  ports?: PortLease;
427
+ /** How the Foreman works out a start command the Run Recipe does not carry (core/
428
+ * start-discovery.ts). A Start turn by default; a test hands in an answer instead. Set it to a
429
+ * function that answers with a reason to turn discovery off. */
430
+ discoverStart?: (input: { task: Pick<TrackerTask, "identifier" | "title">; verification: string; workspace: { path: string }; portEnv: string }) => Promise<StartAnswer>;
426
431
  log?: (line: string) => void;
427
432
  now?: () => number;
428
433
  }
@@ -461,15 +466,57 @@ export function verifierWith(options: VerifierOptions): VerifierFn {
461
466
  log(`verify: ${task.identifier} skipped: ${decision.reason}; the Gates are the whole verification`);
462
467
  return { kind: "skipped", reason: decision.reason };
463
468
  }
464
- log(`verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source})`);
469
+ // The round is wanted; whether it can happen is a second question. A block with Gates and no
470
+ // start is the ordinary shape of a repository that never asked for a browser -- `opsee init`
471
+ // stopped asking, because at setup time nobody knows which of a repository's programs a Task
472
+ // will exercise. Now there is a Task, a Verification section and a Workspace, so the Foreman
473
+ // asks a Start turn rather than giving up.
474
+ let servable = startable(recipe);
475
+ let discovered: string | undefined;
476
+ if (!servable) {
477
+ const ask =
478
+ options.discoverStart ??
479
+ ((input) =>
480
+ discoverStart({
481
+ worker: options.worker,
482
+ account: options.account,
483
+ task: input.task,
484
+ verification: input.verification,
485
+ workspace: input.workspace,
486
+ portEnv: input.portEnv,
487
+ maxTurns: options.maxTurns,
488
+ stallTimeoutMs: options.stallTimeoutMs,
489
+ log,
490
+ now,
491
+ }));
492
+ const answer = await ask({ task, verification: input.verification, workspace, portEnv: recipe.portEnv });
493
+ if (answer.recipe) {
494
+ servable = { ...recipe, ...answer.recipe } satisfies StartableRecipe;
495
+ discovered = answer.recipe.start;
496
+ } else {
497
+ log(`verify: ${task.identifier} could not work out how to serve the app: ${answer.reason}`);
498
+ }
499
+ }
500
+ if (!servable) {
501
+ const missing = `the Run Recipe has no start command, so the app cannot be served (read from ${source})`;
502
+ if (recipe.verify === "browser") {
503
+ log(`verify: ${task.identifier} the Run Recipe says verify: browser but ${missing}; the round failed`);
504
+ return { kind: "failed", reason: `the Run Recipe says verify: browser but ${missing}`, durationMs: 0 };
505
+ }
506
+ log(`verify: ${task.identifier} skipped: ${missing}; the Gates are the whole verification`);
507
+ return { kind: "skipped", reason: missing };
508
+ }
509
+ log(
510
+ `verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source}${discovered ? ", start command from a Start turn" : ""})`,
511
+ );
465
512
 
466
513
  const started = now();
467
514
  const port = await ports.acquire(task.id);
468
515
  const output: string[] = [];
469
- const app = startApp(recipe, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
516
+ const app = startApp(servable, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
470
517
  input.onPid?.("app", app.pid);
471
518
  const appAt = now();
472
- log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
519
+ log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
473
520
  const appRun = (ready: boolean, error?: string): AppRun => ({
474
521
  command: app.command,
475
522
  cwd: workspace.path,
@@ -32,10 +32,16 @@ export const VERIFY_MODES = ["auto", "browser", "none"] as const;
32
32
  export type VerifyMode = (typeof VERIFY_MODES)[number];
33
33
 
34
34
  export interface RunRecipe {
35
- /** Shell command that serves the app; `{port}` and the port variable are substituted. */
36
- start: string;
37
- /** URL that answers once the app is up; `{port}` and the port variable are substituted. */
38
- readinessUrl: string;
35
+ /** Shell command that serves the app; `{port}` and the port variable are substituted.
36
+ *
37
+ * Optional, because only the Verifier's browser round needs it and most repositories never ask
38
+ * for one. The Gates are the rest of the block and they run without it. `opsee init` writes it
39
+ * when the analyzer already knows a dev command and otherwise leaves it out rather than asking
40
+ * a question whose answer is "which of these six programs?" in a monorepo. */
41
+ start?: string;
42
+ /** URL that answers once the app is up; `{port}` and the port variable are substituted. Absent
43
+ * with `start`, present with it. */
44
+ readinessUrl?: string;
39
45
  /** Name of the environment variable set to the port when `start` runs. */
40
46
  portEnv: string;
41
47
  gates: RunRecipeGates;
@@ -81,10 +87,17 @@ function nonEmptyString(value: unknown): string | undefined {
81
87
  export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
82
88
  if (!raw || typeof raw !== "object") throw new Error(`${source} has no foreman block; run opsee init to write the Run Recipe.`);
83
89
  const block = raw as Record<string, unknown>;
90
+ // Neither is required. A block with Gates and no start is a repository that never wanted a
91
+ // browser round, which is most of them; the Verifier says so and the Gates give the Verdict.
92
+ //
93
+ // A start command without a readiness URL is different: it is a half-written recipe. It would
94
+ // start the app and never know it was up, so the round would skip for a reason that looks like
95
+ // "you did not configure this" when the truth is "you configured half of it".
84
96
  const start = nonEmptyString(block.start);
85
97
  const readinessUrl = nonEmptyString(block.readiness_url);
86
- if (!start) throw new Error(`${source}: foreman.start is missing; it is the command that serves the app.`);
87
- if (!readinessUrl) throw new Error(`${source}: foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
98
+ if (start && !readinessUrl) {
99
+ throw new Error(`${source}: foreman.start is set but foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
100
+ }
88
101
  const gatesRaw = block.gates && typeof block.gates === "object" ? (block.gates as Record<string, unknown>) : {};
89
102
  const gates: RunRecipeGates = {};
90
103
  for (const name of ["test", "lint", "typecheck"] as const) {
@@ -97,7 +110,13 @@ export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
97
110
  } catch (error) {
98
111
  throw new Error(`${source}: ${error instanceof Error ? error.message : String(error)}`);
99
112
  }
100
- return { start, readinessUrl, portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV, gates, ...(block.verify === undefined ? {} : { verify }) };
113
+ return {
114
+ ...(start === undefined ? {} : { start }),
115
+ ...(readinessUrl === undefined ? {} : { readinessUrl }),
116
+ portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV,
117
+ gates,
118
+ ...(block.verify === undefined ? {} : { verify }),
119
+ };
101
120
  }
102
121
 
103
122
  /** Resolves the Run Recipe of the repo at `root`. The JSON copy is authoritative when present
@@ -146,7 +165,17 @@ export interface AppHandle {
146
165
  * wrapper spawned underneath it. The child goes on this process's live registry
147
166
  * (worker-process.ts `trackLiveProcess`) beside the Workers, so a Foreman that is signalled or
148
167
  * exits stops the app with them rather than orphaning it. */
149
- export function startApp(recipe: RunRecipe, options: StartOptions): AppHandle {
168
+ /** A recipe that can actually serve the app: both halves present. `startApp` takes this rather
169
+ * than a RunRecipe so a caller cannot forget the check, and the Verifier's "cannot start the app"
170
+ * branch is the one place that decides what to do about it. */
171
+ export type StartableRecipe = RunRecipe & { start: string; readinessUrl: string };
172
+
173
+ /** The recipe when it can serve the app, undefined when it cannot. */
174
+ export function startable(recipe: RunRecipe): StartableRecipe | undefined {
175
+ return recipe.start && recipe.readinessUrl ? (recipe as StartableRecipe) : undefined;
176
+ }
177
+
178
+ export function startApp(recipe: StartableRecipe, options: StartOptions): AppHandle {
150
179
  const command = substitutePort(recipe.start, options.port, recipe.portEnv);
151
180
  const readinessUrl = substitutePort(recipe.readinessUrl, options.port, recipe.portEnv);
152
181
  const child = spawn(command, {
@@ -3,26 +3,25 @@ import type { ProjectChoice } from "./project.js";
3
3
  import type { RunRecipe } from "../foreman/run-recipe.js";
4
4
  import type { RecipeDefaults } from "./run-recipe-config.js";
5
5
 
6
- /** Asks for the Run Recipe with the inferred values as defaults (Enter keeps them). Without a
7
- * terminal the defaults stand as they are, and null comes back when no start command could be
8
- * inferred, since a recipe without one cannot start anything. */
9
- export async function askRecipe(defaults: RecipeDefaults): Promise<RunRecipe | null> {
10
- if (!process.stdin.isTTY) {
11
- return defaults.start ? { ...defaults, start: defaults.start } : null;
12
- }
6
+ /**
7
+ * Asks for the one Run Recipe value nothing else can infer: the typecheck Gate.
8
+ *
9
+ * It no longer asks how to start the app. That question has no answer at setup time in a
10
+ * repository with more than one program -- "the app" means whichever one a Task's Verification
11
+ * happens to exercise, which is not known until there is a Task. The analyzer's dev command is
12
+ * still written when it exists, and the Verifier works the rest out when a browser round is
13
+ * actually wanted.
14
+ *
15
+ * Without a terminal the inferred values stand as they are.
16
+ */
17
+ export async function askRecipe(defaults: RecipeDefaults): Promise<RunRecipe> {
18
+ if (!process.stdin.isTTY) return { ...defaults };
13
19
  const rl = createInterface({ input: process.stdin, output: process.stdout });
14
- const ask = async (label: string, fallback: string | undefined): Promise<string> => {
15
- const answer = (await rl.question(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
16
- return answer || fallback || "";
17
- };
18
20
  try {
19
- rl.write("Run Recipe (how the Foreman starts the app for verification; {port} is the Worker's port):\n");
20
- const start = await ask("Start command", defaults.start);
21
- if (!start) return null;
22
- const readinessUrl = await ask("Readiness URL", defaults.readinessUrl);
23
- const portEnv = await ask("Port variable", defaults.portEnv);
24
- const typecheck = await ask("Typecheck command (blank for none)", defaults.gates.typecheck);
25
- return { start, readinessUrl, portEnv, gates: { ...defaults.gates, typecheck: typecheck || undefined } };
21
+ const fallback = defaults.gates.typecheck;
22
+ const answer = (await rl.question(`Typecheck command for the Foreman's Gates (blank for none)${fallback ? ` [${fallback}]` : ""}: `)).trim();
23
+ const typecheck = answer || fallback;
24
+ return { ...defaults, gates: { ...defaults.gates, typecheck: typecheck || undefined } };
26
25
  } finally {
27
26
  rl.close();
28
27
  }
@@ -33,18 +33,16 @@ export function commandsOf(files: OpseeConfigFiles): Record<string, string> {
33
33
  * Gate has no analyzer equivalent and is only ever a flag or a prompt answer. */
34
34
  export function inferRecipe(files: OpseeConfigFiles, flags: RecipeFlags): RecipeDefaults {
35
35
  const commands = commandsOf(files);
36
+ const start = flags.start ?? commands.dev ?? commands.start;
36
37
  return {
37
- start: flags.start ?? commands.dev ?? commands.start,
38
- readinessUrl: flags.readinessUrl ?? DEFAULT_READINESS_URL,
38
+ // The pair travels together or not at all: a readiness URL with nothing to start is a line
39
+ // that answers a question nobody asked, and `recipeFromConfig` rejects the other half-pair.
40
+ ...(start === undefined ? {} : { start, readinessUrl: flags.readinessUrl ?? DEFAULT_READINESS_URL }),
39
41
  portEnv: flags.portEnv ?? DEFAULT_PORT_ENV,
40
42
  gates: { test: commands.test, lint: commands.lint, typecheck: flags.typecheck },
41
43
  };
42
44
  }
43
45
 
44
- /** Why `init` skipped the recipe when nothing said how to start the app. */
45
- export const NO_START_COMMAND =
46
- "no start command: pass --start <cmd>, answer the prompt on a terminal, or set commands.dev in .opsee/config";
47
-
48
46
  /** The recipe a config file already carries, so a block present in one of the two files is copied
49
47
  * into the other rather than asked for again; null when neither has a block that can serve as a
50
48
  * recipe. The YAML is the analyzer's primary file and wins when both have one. */
@@ -68,12 +66,13 @@ function gateEntries(gates: RunRecipeGates): Array<[string, string]> {
68
66
  }
69
67
 
70
68
  function yamlRecipeBlock(recipe: RunRecipe, indent: string): string {
71
- const lines = [
72
- `${RECIPE_KEY}:`,
73
- `${indent}start: ${yamlScalar(recipe.start)}`,
74
- `${indent}readiness_url: ${yamlScalar(recipe.readinessUrl)}`,
75
- `${indent}port_env: ${yamlScalar(recipe.portEnv)}`,
76
- ];
69
+ // start and readiness_url only when something already knew them: the analyzer's dev command, or
70
+ // an --start flag. A block with neither is a complete recipe for a repository whose Verdict comes
71
+ // from its Gates.
72
+ const lines = [`${RECIPE_KEY}:`];
73
+ if (recipe.start) lines.push(`${indent}start: ${yamlScalar(recipe.start)}`);
74
+ if (recipe.readinessUrl) lines.push(`${indent}readiness_url: ${yamlScalar(recipe.readinessUrl)}`);
75
+ lines.push(`${indent}port_env: ${yamlScalar(recipe.portEnv)}`);
77
76
  const gates = gateEntries(recipe.gates);
78
77
  if (gates.length > 0) {
79
78
  lines.push(`${indent}gates:`);