@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,227 @@
1
+ /**
2
+ * Reconcile (see ../../../CONTEXT.md; story 51, ADR-0009): what the Foreman does on start and
3
+ * every tick. It compares the Process Table against live Worker processes and the Run Record
4
+ * against Tracker state, then resumes, restarts, or releases each in-flight Task:
5
+ *
6
+ * - The Worker's pid is alive: leave it. A daemon that died left the Worker running detached; it
7
+ * finishes on its own, and the tick after it exits resumes its session to collect the report.
8
+ * - The Run Record already has the attempt event for this attempt: the turn was settled (the
9
+ * attempt event is the commit) and only the row is stale, so release it: remove the row.
10
+ * - Dead, with a session id: resume the turn by session id in the same Workspace; the attempt
11
+ * event it ends with carries a `resumed` marker.
12
+ * - Dead, no session id: the Worker never got as far as `started`, so nothing can be resumed;
13
+ * dispatch the Task again, which is attempt+1.
14
+ * - A row under another Account is not this Run's to touch (half-finished work never changes
15
+ * Account); it waits for a Run under its own.
16
+ *
17
+ * The human's steering marks (stories 53-56) come before the process facts: a `cancelled` row
18
+ * whose attempt is not yet recorded is settled as cancelled once its pid is dead (the owner of the
19
+ * turn died, or nobody owned it; while the pid lives, the owner settles it when the stream ends);
20
+ * an `attached` row is left alone whatever its pid, a human has the session; a `released` row
21
+ * resumes whatever its pid says, with the prompt saying an attended turn happened. And
22
+ * while the Initiative is paused (story 55) nothing new is dispatched: a row with no session to
23
+ * resume waits for the resume instead of being restarted.
24
+ *
25
+ * `planReconcile` is the pure decision, tested on its own; `reconcile` reads the Run Record for
26
+ * each row, applies the plan through the callbacks run.ts supplies, and never lets one row's
27
+ * failure stop the others: a Tracker that cannot be reached leaves every row for the next tick.
28
+ */
29
+ import type { Account } from "../account.js";
30
+ import type { TrackerAdapter } from "../tracker-adapter.js";
31
+ import type { ProcessTableApi, WorkerRow } from "./process-table.js";
32
+ import { pidIsAlive } from "./process-table.js";
33
+ import { isTriageSummary } from "./triage.js";
34
+ import { stopProcessByPid } from "../process-group.js";
35
+ import { errorMessage, KILL_GRACE_MS } from "../worker-process.js";
36
+
37
+ /** The Status Label a Task wears while a Worker has it (mirrors STATUS_LABELS.running in run.ts,
38
+ * kept here so reconcile does not import the loop). */
39
+ const RUNNING_LABEL = "foreman:running";
40
+
41
+ export type ReconcileDecision =
42
+ | { action: "leave"; row: WorkerRow; reason: string }
43
+ | { action: "skip"; row: WorkerRow; reason: string }
44
+ | { action: "release"; row: WorkerRow; reason: string; finish?: { outcome: string } }
45
+ /** `released` is true when a human had the session as an attended turn and handed it back. */
46
+ | { action: "resume"; row: WorkerRow; sessionId: string; released: boolean }
47
+ | { action: "restart"; row: WorkerRow }
48
+ /** `foreman cancel` stopped the Worker and nobody closed the attempt: record it as cancelled. */
49
+ | { action: "cancel"; row: WorkerRow };
50
+
51
+ export interface RowFacts {
52
+ alive: boolean;
53
+ /** The outcome of the Run Record's attempt event for this row's attempt, when one exists. */
54
+ settledOutcome?: string;
55
+ /** The Task still wears `foreman:running`: the attempt event was written but the Foreman died
56
+ * before the comment and the board followed, so the release must finish that settlement. */
57
+ stillRunning?: boolean;
58
+ /** New dispatch is paused for the Initiative (story 55). */
59
+ paused?: boolean;
60
+ /** This Foreman has the turn in flight right now, in a Slot of the Run that is reconciling
61
+ * (OPS-276). Only true once dispatch became concurrent: with one Worker at a time, every row a
62
+ * tick saw belonged to some earlier process. */
63
+ owned?: boolean;
64
+ }
65
+
66
+ export function planReconcile(row: WorkerRow, account: Account, facts: RowFacts): ReconcileDecision {
67
+ if (row.account !== account.name) return { action: "skip", row, reason: `under Account "${row.account}", not "${account.name}"` };
68
+ // A turn this Foreman is driving is nobody else's to settle, whatever the row says: the pid may
69
+ // be unset (an adapter that has none), the session may not have arrived yet, and restarting it
70
+ // beside the live Worker would dispatch the Task twice.
71
+ if (facts.owned) return { action: "leave", row, reason: "this Foreman has the turn in flight" };
72
+ if (facts.settledOutcome !== undefined) {
73
+ return {
74
+ action: "release",
75
+ row,
76
+ reason: `attempt ${row.attempt} already recorded as ${facts.settledOutcome}${facts.stillRunning ? ", but the Task still says running: finishing its settlement" : ""}`,
77
+ ...(facts.stillRunning ? { finish: { outcome: facts.settledOutcome } } : {}),
78
+ };
79
+ }
80
+ if (row.control === "cancelled") {
81
+ // The cancel command marks the row before it stops the process; if it died in between, the
82
+ // Worker is still running and its owner settles the attempt as cancelled when the stream ends.
83
+ // Closing it here as well would record the attempt twice.
84
+ if (facts.alive) return { action: "leave", row, reason: `cancelled but pid ${row.pid} is still alive; the owner of the turn, or another foreman cancel, closes it` };
85
+ return { action: "cancel", row };
86
+ }
87
+ if (row.control === "attached") return { action: "leave", row, reason: "attached: a human has the session until foreman release" };
88
+ // A released row resumes whatever its pid says: attach stopped the unattended turn and cleared
89
+ // the pid, and a pid it might still carry (an attach from before that) is dead or recycled.
90
+ if (row.control === "released" && row.sessionId) return { action: "resume", row, sessionId: row.sessionId, released: true };
91
+ if (facts.alive) return { action: "leave", row, reason: `pid ${row.pid} is alive` };
92
+ if (row.sessionId) return { action: "resume", row, sessionId: row.sessionId, released: false };
93
+ if (facts.paused) return { action: "leave", row, reason: "no session to resume and the Run is paused: dispatched again after foreman resume" };
94
+ return { action: "restart", row };
95
+ }
96
+
97
+ export interface ReconcileDeps<T> {
98
+ table: Pick<ProcessTableApi, "liveWorkers" | "removeWorker"> & Partial<Pick<ProcessTableApi, "setAppPid">>;
99
+ tracker: Pick<TrackerAdapter, "readRunRecord" | "getTask">;
100
+ account: Account;
101
+ log: (line: string) => void;
102
+ /** Finishes a settlement the attempt event began and a crash cut short: the comment and the
103
+ * board. Absent, an unfinished settlement is only logged. */
104
+ finishSettlement?: (row: WorkerRow, outcome: string) => Promise<void>;
105
+ /** Runs the rest of the turn from the stored session and settles it; `released` says a human
106
+ * had it as an attended turn in between. Undefined when the turn could not be started at all —
107
+ * a Run that is stopping does not wait for one of its Slots — and the row is left as it is. */
108
+ resume: (row: WorkerRow, sessionId: string, released: boolean) => Promise<T | undefined>;
109
+ /** Dispatches the Task again, attempt+1. Undefined has the same meaning as on `resume`. */
110
+ restart: (row: WorkerRow) => Promise<T | undefined>;
111
+ /** Closes the attempt of a Worker `foreman cancel` stopped: the attempt event, the comment,
112
+ * the board, the row. Absent, a cancelled row is only logged. */
113
+ cancel?: (row: WorkerRow) => Promise<T | undefined>;
114
+ /** Whether new dispatch is paused for the Initiative; false by default. */
115
+ paused?: (initiativeId: number) => boolean;
116
+ /** Whether this Foreman has the Task's turn in flight in one of its Slots; false by default,
117
+ * which is what a Reconcile outside a Run (the daemon's idle tick) wants. */
118
+ owns?: (taskId: number) => boolean;
119
+ isAlive?: (pid: number | undefined) => boolean;
120
+ /** Stops the app a Verifier round left running (`WorkerRow.appPid`) when the Worker that owned
121
+ * the round is gone: a Foreman that died mid-round orphaned it. Resolves true when something was
122
+ * stopped. `stopProcessByPid` with the row's start time by default; a test scripts one. */
123
+ stopApp?: (row: WorkerRow) => Promise<boolean>;
124
+ }
125
+
126
+ const defaultStopApp = async (row: WorkerRow): Promise<boolean> => (row.appPid === undefined ? false : stopProcessByPid(row.appPid, KILL_GRACE_MS, { startedAt: row.startedAt }));
127
+
128
+ export interface ReconcileOutcome<T> {
129
+ decisions: ReconcileDecision[];
130
+ /** What the resumed and restarted turns returned, in order; a turn that was not started at all
131
+ * (no Slot, the Run stopping) contributes nothing. */
132
+ results: T[];
133
+ }
134
+
135
+ /** Reconciles the Process Table rows of one Initiative. */
136
+ export async function reconcile<T>(deps: ReconcileDeps<T>, initiativeId: number): Promise<ReconcileOutcome<T>> {
137
+ const isAlive = deps.isAlive ?? pidIsAlive;
138
+ const decisions: ReconcileDecision[] = [];
139
+ const results: T[] = [];
140
+ const rows = deps.table.liveWorkers({ initiativeId });
141
+ if (rows.length) deps.log(`reconcile: ${rows.length} Process Table ${rows.length === 1 ? "row" : "rows"} for Initiative ${initiativeId}`);
142
+ const paused = deps.paused?.(initiativeId) ?? false;
143
+ for (const row of rows) {
144
+ let facts: RowFacts;
145
+ try {
146
+ const settled = await settledOutcome(deps.tracker, row);
147
+ const stillRunning = settled === undefined ? false : (await deps.tracker.getTask(row.taskId)).task.labels.includes(RUNNING_LABEL);
148
+ facts = { alive: isAlive(row.pid), settledOutcome: settled, stillRunning, paused, owned: deps.owns?.(row.taskId) ?? false };
149
+ } catch (error) {
150
+ deps.log(`reconcile: ${row.identifier}: could not read the Run Record, leaving it for the next tick: ${errorMessage(error)}`);
151
+ continue;
152
+ }
153
+ const decision = planReconcile(row, deps.account, facts);
154
+ decisions.push(decision);
155
+ // A Verifier round's app outlives the Foreman that started it (spawned detached). Once the
156
+ // Worker of the row is gone, nothing owns that app any more, whatever else happens to the row.
157
+ if (row.appPid !== undefined && !facts.alive && decision.action !== "skip") {
158
+ try {
159
+ const stopped = await (deps.stopApp ?? defaultStopApp)(row);
160
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: the Verifier's app (pid ${row.appPid}) ${stopped ? "was still running and has been stopped" : "is already gone"}`);
161
+ } catch (error) {
162
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: could not stop the Verifier's app (pid ${row.appPid}): ${errorMessage(error)}`);
163
+ }
164
+ if ("setAppPid" in deps.table) (deps.table as Pick<ProcessTableApi, "setAppPid">).setAppPid(row.taskId, undefined);
165
+ }
166
+ switch (decision.action) {
167
+ case "leave":
168
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: ${decision.reason}, leaving it`);
169
+ break;
170
+ case "skip":
171
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: ${decision.reason}, skipping`);
172
+ break;
173
+ case "release":
174
+ if (decision.finish) {
175
+ if (deps.finishSettlement) await deps.finishSettlement(row, decision.finish.outcome);
176
+ else deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: settlement unfinished and nothing to finish it with`);
177
+ }
178
+ deps.table.removeWorker(row.taskId);
179
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: ${decision.reason}, released`);
180
+ break;
181
+ case "resume":
182
+ deps.log(
183
+ decision.released
184
+ ? `reconcile: ${row.identifier} attempt ${row.attempt}: released by the human, resuming session ${decision.sessionId} unattended in ${row.workspace}`
185
+ : `reconcile: ${row.identifier} attempt ${row.attempt}: Worker gone (pid ${row.pid ?? "unknown"}), resuming session ${decision.sessionId} in ${row.workspace}`,
186
+ );
187
+ push(results, await deps.resume(row, decision.sessionId, decision.released));
188
+ break;
189
+ case "restart":
190
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: Worker gone before it started a session, dispatching again`);
191
+ push(results, await deps.restart(row));
192
+ break;
193
+ case "cancel":
194
+ if (deps.cancel) {
195
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: cancelled by the human and not yet recorded, closing the attempt`);
196
+ push(results, await deps.cancel(row));
197
+ } else {
198
+ deps.log(`reconcile: ${row.identifier} attempt ${row.attempt}: cancelled by the human and nothing to close the attempt with`);
199
+ }
200
+ break;
201
+ }
202
+ }
203
+ return { decisions, results };
204
+ }
205
+
206
+ function push<T>(results: T[], result: T | undefined): void {
207
+ if (result !== undefined) results.push(result);
208
+ }
209
+
210
+ /**
211
+ * Whether this row's attempt has already been settled on the Run Record, and how it ended.
212
+ *
213
+ * The Triage turn is skipped: it is given the attempt number the implementer turn is about to take
214
+ * and writes its event *before* the dispatch event, so an attempt can carry two attempt events with
215
+ * triage first (core/triage.ts `TRIAGE_MARKER`). Taking the first match would read a drafting turn
216
+ * that completed as the settlement of an implementer turn that is still running — the row would be
217
+ * dropped instead of resumed, its session lost, and the Task blocked with "the Foreman stopped
218
+ * before finishing". That is precisely the case Reconcile exists for.
219
+ */
220
+ async function settledOutcome(tracker: Pick<TrackerAdapter, "readRunRecord">, row: WorkerRow): Promise<string | undefined> {
221
+ const { events } = await tracker.readRunRecord(row.initiativeId, { taskId: row.taskId, kinds: ["attempt"] });
222
+ for (const e of events) {
223
+ const p = e.payload?.payload;
224
+ if (p?.case === "attempt" && p.value.attempt === row.attempt && !isTriageSummary(p.value.summary)) return p.value.outcome;
225
+ }
226
+ return undefined;
227
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Completion Report -> Tracker writes (story 44, ADR-0008). A Worker's report becomes typed
3
+ * Initiative memory entries with the Task as source: one `outcome` entry carrying the summary,
4
+ * decisions and what was tried; one `learning` entry per Proposed Learning; one `blocker` entry
5
+ * per blocker. A one-line comment on the Task points a reader of the Task alone at them. Nothing
6
+ * here talks to a Tracker; the Run loop hands the entries to the adapter.
7
+ */
8
+ import type { CompletionReport } from "../completion-report.js";
9
+ import type { MemoryEntry, TrackerTask } from "../tracker-adapter.js";
10
+
11
+ function bulleted(title: string, items: string[]): string {
12
+ if (items.length === 0) return "";
13
+ return `\n\n${title}:\n${items.map((item) => `- ${item}`).join("\n")}`;
14
+ }
15
+
16
+ /** The `outcome` entry: the report's summary with its decisions and attempts under it. */
17
+ export function outcomeBody(task: Pick<TrackerTask, "identifier">, report: CompletionReport): string {
18
+ return (
19
+ `Completion Report for ${task.identifier} (outcome: ${report.outcome})\n\n${report.summary.trim()}` +
20
+ bulleted("Decisions", report.decisions) +
21
+ bulleted("Tried", report.tried) +
22
+ (report.handOff?.prUrl ? `\n\nHand-off: ${report.handOff.prUrl}` : report.handOff?.branch ? `\n\nBranch: ${report.handOff.branch}` : "")
23
+ );
24
+ }
25
+
26
+ /** Every memory entry a report produces, outcome first, all sourced to the Task. */
27
+ export function reportToMemory(task: Pick<TrackerTask, "id" | "identifier">, report: CompletionReport): MemoryEntry[] {
28
+ const source = { sourceTaskId: task.id, sourceUrl: report.handOff?.prUrl };
29
+ return [
30
+ { kind: "outcome", body: outcomeBody(task, report), ...source },
31
+ ...report.proposedLearnings.map((body): MemoryEntry => ({ kind: "learning", body, ...source })),
32
+ ...report.blockers.map((body): MemoryEntry => ({ kind: "blocker", body, ...source })),
33
+ ];
34
+ }
35
+
36
+ /** The linking comment: one line, so a Task's thread stays readable while the detail lives in the
37
+ * memory log. Names the outcome entry by id and the other entries by count; `note` is what the
38
+ * Run loop adds about the Hand-off (incomplete, or not linked), already punctuated. */
39
+ export function reportComment(report: CompletionReport, memoryIds: number[], note = ""): string {
40
+ const [outcomeId] = memoryIds;
41
+ const counts = [
42
+ report.proposedLearnings.length > 0 && `${report.proposedLearnings.length} proposed learning${report.proposedLearnings.length === 1 ? "" : "s"}`,
43
+ report.blockers.length > 0 && `${report.blockers.length} blocker${report.blockers.length === 1 ? "" : "s"}`,
44
+ ].filter((c): c is string => !!c);
45
+ const where = `Initiative memory (outcome entry #${outcomeId}${counts.length ? `, ${counts.join(", ")}` : ""})`;
46
+ const handOff = report.handOff?.prUrl ? `; Hand-off ${report.handOff.prUrl}` : "";
47
+ const reason = report.outcome === "done" ? "" : ` ${firstSentence(report.blockers[0] ?? report.summary)}`;
48
+ return `Foreman: Completion Report (${report.outcome}) recorded in ${where}${handOff}.${reason}${note}`;
49
+ }
50
+
51
+ /** The comment for a turn that ended without a report: the typed reason and the vendor's text. */
52
+ export function failureComment(reason: string, message: string): string {
53
+ return `Foreman: Worker turn failed (${reason}): ${firstSentence(message)}`;
54
+ }
55
+
56
+ function firstSentence(text: string): string {
57
+ const trimmed = text.trim().replace(/\s+/g, " ");
58
+ const end = trimmed.search(/[.!?](\s|$)/);
59
+ return end === -1 ? trimmed : trimmed.slice(0, end + 1);
60
+ }