@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.
- package/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { uptime } from "node:os";
|
|
4
|
+
import { AccountError, type Account } from "../foreman/account.js";
|
|
5
|
+
import type { AccountStore } from "../foreman/account-store.js";
|
|
6
|
+
import { defectFilerWith } from "../foreman/core/defects.js";
|
|
7
|
+
import { baseGateCommands, gatesWith, noGatesWarning } from "../foreman/core/gates.js";
|
|
8
|
+
import { handOffWith } from "../foreman/core/handoff.js";
|
|
9
|
+
import { DEFAULT_LEARNINGS_FILE, learningsFileOf, learningsRefusedWith, learningsWith } from "../foreman/core/learnings.js";
|
|
10
|
+
import { OutboxTracker } from "../foreman/core/outbox-tracker.js";
|
|
11
|
+
import { otherForemanWorkersOn, pidIsAlive, type ProcessTable } from "../foreman/core/process-table.js";
|
|
12
|
+
import { ForemanError, isHandedOff, runForeman, type BlockedTask, type Dispatch, type RunAccount, type RunDeps, type RunOutcome, type RunOptions } from "../foreman/core/run.js";
|
|
13
|
+
import { count, printableOneLine } from "../foreman/core/text.js";
|
|
14
|
+
import { summarize } from "../foreman/core/summary.js";
|
|
15
|
+
import type { TranscriptStore } from "../foreman/core/transcripts.js";
|
|
16
|
+
import { verifierWith } from "../foreman/core/verifier.js";
|
|
17
|
+
import { guardedGit, repoRootOf, runGit, WorkspaceManager, type GitRunner } from "../foreman/core/workspace.js";
|
|
18
|
+
import { remoteApiFor, runCli, type RemoteApi } from "../foreman/remote-api.js";
|
|
19
|
+
import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
|
|
20
|
+
import type { WorkerAdapter } from "../foreman/worker-adapter.js";
|
|
21
|
+
|
|
22
|
+
/** Silence a Worker may keep before the turn is `stalled`: long enough for a real test run, short
|
|
23
|
+
* enough that a wedged Worker does not hold the Run all night (story 33 tunes this per Account). */
|
|
24
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60_000;
|
|
25
|
+
|
|
26
|
+
/** What is meaningful only on this machine (ADR-0009) and both commands share: the Process Table,
|
|
27
|
+
* the transcript store and the daemon's pid file. Optional on `foreman run` so the loop can be
|
|
28
|
+
* exercised without a file; the real command always has it. */
|
|
29
|
+
export interface ForemanLocal {
|
|
30
|
+
table: ProcessTable;
|
|
31
|
+
transcripts: TranscriptStore;
|
|
32
|
+
pidFile: string;
|
|
33
|
+
/** How a live daemon is told from a stale pid file; the real probe by default, a test's otherwise. */
|
|
34
|
+
probe?: DaemonProbe;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What `liveDaemonPid` asks the machine, injectable so a test can be any boot and any process. */
|
|
38
|
+
export interface DaemonProbe {
|
|
39
|
+
/** The current time, ms since the epoch. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** How long this boot has been up, in ms (os.uptime). */
|
|
42
|
+
uptimeMs?: () => number;
|
|
43
|
+
/** Whether a signal could reach the pid (process-table.ts pidIsAlive). */
|
|
44
|
+
isAlive?: (pid: number) => boolean;
|
|
45
|
+
/** The command line of the process with that pid, or undefined when it cannot be read (no ps, no
|
|
46
|
+
* /proc, the process gone in between); undefined means "cannot tell", not "not ours". */
|
|
47
|
+
commandOf?: (pid: number) => string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The real probe: `/proc/<pid>/cmdline` where there is one, `ps -o command=` elsewhere. */
|
|
51
|
+
export const realDaemonProbe: Required<DaemonProbe> = {
|
|
52
|
+
now: () => Date.now(),
|
|
53
|
+
uptimeMs: () => uptime() * 1000,
|
|
54
|
+
isAlive: pidIsAlive,
|
|
55
|
+
commandOf: (pid) => {
|
|
56
|
+
try {
|
|
57
|
+
if (process.platform === "linux") return readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ");
|
|
58
|
+
return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** What both commands need to build a Run's deps for one Account. */
|
|
66
|
+
export interface SharedDeps {
|
|
67
|
+
tracker: TrackerAdapter;
|
|
68
|
+
adapterFor: (account: Account) => WorkerAdapter;
|
|
69
|
+
out: (line: string) => void;
|
|
70
|
+
/** The registered Accounts. Where a rate limit's Paused state is written and read back
|
|
71
|
+
* (story 30), and where the sibling Accounts a Run may Fail over to come from (story 31). Both
|
|
72
|
+
* commands have one; it is optional here so a test can build a Run's deps without a store, which
|
|
73
|
+
* then Pauses nothing and Fails over nowhere. */
|
|
74
|
+
store?: AccountStore;
|
|
75
|
+
/** Test seams; the real Workspace manager, code host, Hand-off and loop by default. Every git
|
|
76
|
+
* call the commands make goes through `git`, the guarded runner (ADR-0003). */
|
|
77
|
+
workspacesFor?: (repoRoot: string, log: (line: string) => void, git: GitRunner) => RunDeps["workspaces"];
|
|
78
|
+
remoteFor?: (originUrl: string, repoRoot: string) => RemoteApi;
|
|
79
|
+
handOff?: RunDeps["handOff"];
|
|
80
|
+
/** The Gates after a Hand-off; the base's Run Recipe commands (`gatesWith`) by default. */
|
|
81
|
+
gates?: RunDeps["gates"];
|
|
82
|
+
gateRetries?: number;
|
|
83
|
+
/** The Verifier after the Gates; the base's Run Recipe app and a Playwright MCP Worker on the
|
|
84
|
+
* same Account (`verifierWith`) by default. */
|
|
85
|
+
verifier?: RunDeps["verifier"];
|
|
86
|
+
/** The Defect filer after a failed Verdict; the real one (`defectFilerWith`) by default. */
|
|
87
|
+
defects?: RunDeps["defects"];
|
|
88
|
+
learnings?: RunDeps["learnings"];
|
|
89
|
+
run?: (deps: RunDeps, options: RunOptions) => Promise<RunOutcome>;
|
|
90
|
+
install?: RunDeps["install"];
|
|
91
|
+
isAlive?: RunDeps["isAlive"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Options of `opsee foreman run`. */
|
|
95
|
+
export interface ForemanRunArgs {
|
|
96
|
+
initiativeId: number;
|
|
97
|
+
account?: string;
|
|
98
|
+
once: boolean;
|
|
99
|
+
taskId?: number;
|
|
100
|
+
maxTurns?: number;
|
|
101
|
+
stallTimeoutMs?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ForemanRunDeps extends SharedDeps {
|
|
105
|
+
store: AccountStore;
|
|
106
|
+
/** The repository the Run works on: the checkout the command runs in. */
|
|
107
|
+
repoRoot: () => Promise<string | undefined>;
|
|
108
|
+
local?: ForemanLocal;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The pid in the daemon's pid file when that daemon is alive; undefined for no file, one that
|
|
112
|
+
* does not hold a number, or a stale one. Stale is: written before this boot (its mtime predates
|
|
113
|
+
* the uptime; a reboot restarts pids low, so last boot's number is often some other process's
|
|
114
|
+
* today, and pidIsAlive counts EPERM as alive), naming a pid nothing answers at, or naming a pid
|
|
115
|
+
* whose command line can be read and is not a Foreman. A command line that cannot be read is not
|
|
116
|
+
* held against the pid: better to refuse a start than to run two daemons. */
|
|
117
|
+
export function liveDaemonPid(pidFile: string, probe: DaemonProbe = {}): number | undefined {
|
|
118
|
+
const { now, uptimeMs, isAlive, commandOf } = { ...realDaemonProbe, ...probe };
|
|
119
|
+
if (!existsSync(pidFile)) return undefined;
|
|
120
|
+
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
121
|
+
if (!Number.isInteger(pid) || pid <= 0) return undefined;
|
|
122
|
+
if (statSync(pidFile).mtimeMs < now() - uptimeMs()) return undefined;
|
|
123
|
+
if (!isAlive(pid)) return undefined;
|
|
124
|
+
const command = commandOf(pid);
|
|
125
|
+
return command === undefined || /foreman/.test(command) ? pid : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refuses a foreground Run on an Account another Foreman is already running Workers under
|
|
130
|
+
* (story 27, ADR-0013).
|
|
131
|
+
*
|
|
132
|
+
* The cap is the Account's, but `new Slots(cap)` is the Run's: two `foreman run` in two terminals
|
|
133
|
+
* on one Account would each fill their own Slots and put 2 x cap Workers on one vendor identity —
|
|
134
|
+
* exactly what `registerAccount`'s twin check refuses to let two *Accounts* do to one login, and
|
|
135
|
+
* what the README's "what one person at one keyboard looks like" claims. The Process Table is the
|
|
136
|
+
* only state the two processes share, so it is what is asked: one `liveWorkers({ account })` query.
|
|
137
|
+
*
|
|
138
|
+
* Rows whose Worker is gone are not held against the new Run — they are what Reconcile exists to
|
|
139
|
+
* settle, and refusing on them would leave every crashed Run needing a manual cleanup before
|
|
140
|
+
* anything could start again. That leaves one hole, deliberately: a live Foreman whose Worker
|
|
141
|
+
* Adapter reports no pid has rows this cannot tell from a dead one's, and two Runs could still
|
|
142
|
+
* overlap there. Closing it wants a heartbeat or a lock in the table itself, which is its own
|
|
143
|
+
* change (the same fact `Scheduling.owns` is in-process only for).
|
|
144
|
+
*/
|
|
145
|
+
export function refuseSecondRunOnAccount(table: Pick<ProcessTable, "liveWorkers">, account: Account, isAlive: (pid: number | undefined) => boolean = pidIsAlive): void {
|
|
146
|
+
const live = table.liveWorkers({ account: account.name }).filter((row) => row.pid !== undefined && isAlive(row.pid));
|
|
147
|
+
if (live.length === 0) return;
|
|
148
|
+
const which = live.map((row) => `${row.identifier} (pid ${row.pid})`).join(", ");
|
|
149
|
+
throw new ForemanError(
|
|
150
|
+
`Account "${account.name}" already has ${count(live.length, "Worker")} running under another Foreman: ${which}. ` +
|
|
151
|
+
`The cap is what one vendor identity may run at once (ADR-0013), and a second Run here would have its own Slots and double it. ` +
|
|
152
|
+
`Wait for that Foreman, or queue this Run for a daemon (opsee foreman up), or run it on another Account (--account <name>).`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Refuses a Run whose own Account is quarantined (OPS-288).
|
|
158
|
+
*
|
|
159
|
+
* The Run loop would notice anyway — `quarantinedNow` drops the Lane and the tick ends the Run — but
|
|
160
|
+
* only after opening the Process Table, Reconciling and asking the Tracker for Ready Tasks, and the
|
|
161
|
+
* line it prints then is about Lanes rather than about the Account the operator named. Refused here
|
|
162
|
+
* instead, in the words of the thing they can fix.
|
|
163
|
+
*
|
|
164
|
+
* Deliberately not in `chooseAccount`, which `foreman plan` and the daemon also call: an attended
|
|
165
|
+
* planning session on a quarantined Account is the vendor prompting a human who is sitting there to
|
|
166
|
+
* log in, which is the remedy rather than the problem.
|
|
167
|
+
*/
|
|
168
|
+
export function refuseQuarantinedRun(account: Account): void {
|
|
169
|
+
if (!account.quarantined) return;
|
|
170
|
+
throw new ForemanError(
|
|
171
|
+
`Account "${account.name}" is quarantined: its credential was judged dead at ${account.quarantined.at} (${printableOneLine(account.quarantined.reason)}). ` +
|
|
172
|
+
`A quarantined Account contributes no Slots and is not a Failover target, so this Run would have nowhere to dispatch. ` +
|
|
173
|
+
`Log the vendor back in and run "opsee foreman account resume ${account.name}", or run it on another Account (--account <name>).`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Picks the Account a Run uses: the named one, or the only one registered. */
|
|
178
|
+
export function chooseAccount(accounts: Account[], name: string | undefined): Account {
|
|
179
|
+
if (accounts.length === 0) {
|
|
180
|
+
throw new AccountError("No Account registered; register one with: opsee foreman account add --vendor claude --config-dir <dir>");
|
|
181
|
+
}
|
|
182
|
+
if (name !== undefined) {
|
|
183
|
+
const found = accounts.find((a) => a.name === name);
|
|
184
|
+
if (!found) throw new AccountError(`No Account named "${name}" (have: ${accounts.map((a) => a.name).join(", ")})`);
|
|
185
|
+
return found;
|
|
186
|
+
}
|
|
187
|
+
if (accounts.length > 1) {
|
|
188
|
+
throw new AccountError(`Several Accounts are registered; pass --account <name> (have: ${accounts.map((a) => a.name).join(", ")})`);
|
|
189
|
+
}
|
|
190
|
+
return accounts[0];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The repository's learnings file, or the default with the reason the configured one is refused
|
|
194
|
+
* (`learningsFileOf`). Both commands say so once at start; `runDepsFor` reads it again per Run. */
|
|
195
|
+
export function checkLearningsFile(repoRoot: string): { file: string; refused?: string } {
|
|
196
|
+
try {
|
|
197
|
+
return { file: learningsFileOf(repoRoot) };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { file: DEFAULT_LEARNINGS_FILE, refused: `${error instanceof Error ? error.message : String(error)}; Proposed Learnings are not submitted until it is fixed, and Workers read ${DEFAULT_LEARNINGS_FILE}` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One Run's deps for one Account: the Tracker behind the outbox, the Worker Adapter for the
|
|
204
|
+
* Account's vendor, the Workspace manager and Hand-off for the checkout (every git call through
|
|
205
|
+
* the guarded runner, ADR-0003; the code host chosen from origin up front, so a repository the
|
|
206
|
+
* Foreman could never hand off from is refused before any Worker turn), and the machine-local
|
|
207
|
+
* record. */
|
|
208
|
+
export async function runDepsFor(
|
|
209
|
+
shared: SharedDeps,
|
|
210
|
+
account: Account,
|
|
211
|
+
repoRoot: string,
|
|
212
|
+
local: ForemanLocal | undefined,
|
|
213
|
+
options: { maxTurns?: number; stallTimeoutMs?: number; failover?: boolean },
|
|
214
|
+
): Promise<RunDeps> {
|
|
215
|
+
const outbox = local ? new OutboxTracker(shared.tracker, local.table, shared.out) : undefined;
|
|
216
|
+
const git = guardedGit(runGit, repoRoot);
|
|
217
|
+
const workspaces = (shared.workspacesFor ?? ((root, log, guarded) => new WorkspaceManager(root, { log, git: guarded })))(repoRoot, shared.out, git);
|
|
218
|
+
// The code host serves the Hand-off, the Gates' comment on it and the learnings pull request;
|
|
219
|
+
// it is read from origin once, and only when a step that needs it is not handed in by a test.
|
|
220
|
+
let remote: RemoteApi | undefined;
|
|
221
|
+
const remoteOnce = async () => (remote ??= await remoteFor(shared, git, repoRoot));
|
|
222
|
+
const handOff = shared.handOff ?? handOffWith({ git, remote: await remoteOnce(), repoRoot, log: shared.out });
|
|
223
|
+
// A bad `foreman.learnings_file` must not stop the Run, nor Reconcile of the Workers alive under
|
|
224
|
+
// a daemon that builds these deps every tick: Workers read the default file, and the learnings
|
|
225
|
+
// step reports the refusal instead of submitting.
|
|
226
|
+
const learningsConfig = checkLearningsFile(repoRoot);
|
|
227
|
+
const learningsFile = learningsConfig.file;
|
|
228
|
+
const learnings = shared.learnings ?? (learningsConfig.refused ? learningsRefusedWith(learningsConfig.refused) : learningsWith({ git, remote: await remoteOnce(), repoRoot, workspaces, file: learningsFile, log: shared.out }));
|
|
229
|
+
const worker = shared.adapterFor(account);
|
|
230
|
+
// The Run's own flags win, then the Account's own limits (story 33: a subscription that bills by
|
|
231
|
+
// turn, or one whose Workers are known to go quiet, carries its numbers rather than the Run
|
|
232
|
+
// repeating them), then the CLI's default.
|
|
233
|
+
const maxTurns = options.maxTurns ?? account.maxTurns;
|
|
234
|
+
const stallTimeoutMs = options.stallTimeoutMs ?? account.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
235
|
+
const tracker = outbox ?? shared.tracker;
|
|
236
|
+
// A base that names no Gate command is a whole-Run condition: every Hand-off this Run makes will
|
|
237
|
+
// reach In review with nothing on this machine having run the repository's tests, lint or
|
|
238
|
+
// typecheck. Said once at the start, loudly, rather than only per Task in the morning. Not on the
|
|
239
|
+
// daemon's idle Reconcile tick (`failover: false`), which dispatches nothing and would repeat it
|
|
240
|
+
// every tick, and not when a test hands in its own Gates.
|
|
241
|
+
if (options.failover !== false && !shared.gates) {
|
|
242
|
+
const base = await baseGateCommands(git, repoRoot);
|
|
243
|
+
const warning = noGatesWarning(base.commands, base.source);
|
|
244
|
+
if (warning) shared.out(warning);
|
|
245
|
+
}
|
|
246
|
+
/** One Account's own steps: the adapter for its vendor, and the Gates and Verifier built on it.
|
|
247
|
+
* Both take an Account because both spend the vendor's identity — the Verifier runs a second
|
|
248
|
+
* unattended turn on it, the Gates run their shell without its key. */
|
|
249
|
+
const runAccountFor = (a: Account): RunAccount => {
|
|
250
|
+
const adapter = shared.adapterFor(a);
|
|
251
|
+
// The same precedence as the Run's own Account: the Run's flags, then this Account's own
|
|
252
|
+
// limits, then the CLI's default. Carried on the RunAccount so the implementer turn on this
|
|
253
|
+
// Lane runs under them too — `schedulingFor` (core/run.ts) reads them — rather than under the
|
|
254
|
+
// primary Account's, which would make `foreman account set --max-turns` on a Failover Account
|
|
255
|
+
// a setting the Verifier honoured and the Worker ignored.
|
|
256
|
+
const laneMaxTurns = options.maxTurns ?? a.maxTurns;
|
|
257
|
+
const laneStallTimeoutMs = options.stallTimeoutMs ?? a.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
258
|
+
return {
|
|
259
|
+
account: a,
|
|
260
|
+
worker: adapter,
|
|
261
|
+
maxTurns: laneMaxTurns,
|
|
262
|
+
stallTimeoutMs: laneStallTimeoutMs,
|
|
263
|
+
gates: shared.gates ?? gatesWith({ git, repoRoot, account: a, log: shared.out }),
|
|
264
|
+
verifier: shared.verifier ?? verifierWith({ git, repoRoot, worker: adapter, account: a, transcripts: local?.transcripts, maxTurns: laneMaxTurns, stallTimeoutMs: laneStallTimeoutMs, log: shared.out }),
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
// Failover is a Run's business: the daemon's idle Reconcile builds these deps to settle one
|
|
268
|
+
// Account's rows and dispatches nothing, so it asks for none (`failover: false`) rather than
|
|
269
|
+
// reading the store and logging about siblings every tick.
|
|
270
|
+
const failover = options.failover === false ? [] : failoverAccountsFor(shared, account, local, shared.isAlive ?? pidIsAlive).map(runAccountFor);
|
|
271
|
+
// The Accounts of another vendor, as Lanes only a pinned Task may use (story 24). Built exactly
|
|
272
|
+
// like a Failover Lane — the same adapter, Gates and Verifier for that Account — and asked for
|
|
273
|
+
// under the same condition: the daemon's idle Reconcile dispatches nothing, so it wants none.
|
|
274
|
+
const pinnable = options.failover === false ? [] : pinnableAccountsFor(shared, account, local, shared.isAlive ?? pidIsAlive).map(runAccountFor);
|
|
275
|
+
return {
|
|
276
|
+
tracker,
|
|
277
|
+
worker,
|
|
278
|
+
account,
|
|
279
|
+
failover,
|
|
280
|
+
pinnable,
|
|
281
|
+
accounts: shared.store,
|
|
282
|
+
workspaces,
|
|
283
|
+
install: shared.install,
|
|
284
|
+
handOff,
|
|
285
|
+
// The base's recipe through the guarded git, never the Workspace's (gates.ts); the Account so
|
|
286
|
+
// the Gate shell does not see its key.
|
|
287
|
+
gates: shared.gates ?? gatesWith({ git, repoRoot, account, log: shared.out }),
|
|
288
|
+
gateRetries: shared.gateRetries,
|
|
289
|
+
// The same recipe and Account for the Verifier (verifier.ts): a second unattended turn through
|
|
290
|
+
// the same Worker Adapter, in a scratch directory with Playwright MCP.
|
|
291
|
+
verifier: shared.verifier ?? verifierWith({ git, repoRoot, worker, account, transcripts: local?.transcripts, maxTurns, stallTimeoutMs, log: shared.out }),
|
|
292
|
+
// Every Defect of a failed Verdict becomes a sibling Task of the Initiative (defects.ts);
|
|
293
|
+
// through the same Tracker as the rest of the Run, so an offline Run Record does not stop it.
|
|
294
|
+
defects: shared.defects ?? defectFilerWith({ tracker, log: shared.out }),
|
|
295
|
+
remote,
|
|
296
|
+
learnings,
|
|
297
|
+
learningsFile,
|
|
298
|
+
log: shared.out,
|
|
299
|
+
maxTurns,
|
|
300
|
+
stallTimeoutMs,
|
|
301
|
+
tickDelayMs: 1000,
|
|
302
|
+
processTable: local?.table,
|
|
303
|
+
transcripts: local?.transcripts,
|
|
304
|
+
drainOutbox: outbox ? (id) => outbox.drain(id) : undefined,
|
|
305
|
+
isAlive: shared.isAlive,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The sibling Accounts a Run on `account` may Fail over to (story 31): every other registered
|
|
311
|
+
* Account of the same vendor, minus the ones another Foreman is already running Workers under *at
|
|
312
|
+
* the moment the Run is built*.
|
|
313
|
+
*
|
|
314
|
+
* That last filter is the vendor-terms boundary again (ADR-0013). The cap is what one identity may
|
|
315
|
+
* run at once and the Slots that enforce it are a Run's, so an Account another Foreman already has
|
|
316
|
+
* Workers on is one this Run must not open Slots of its own on — the same reason a second
|
|
317
|
+
* foreground Run on the *primary* Account is refused outright (`refuseSecondRunOnAccount`). Here it
|
|
318
|
+
* is a filter rather than a refusal: the Run has its own Account to work on, and losing a Failover
|
|
319
|
+
* target is a narrower fleet, not a reason to refuse the work.
|
|
320
|
+
*
|
|
321
|
+
* This pass alone does **not** keep the boundary, and does not claim to: a Lane that is idle now
|
|
322
|
+
* may be a second Foreman's before this Run ever fails over onto it (that Foreman's own
|
|
323
|
+
* `refuseSecondRunOnAccount` sees nothing here either, since this Run has not touched the Account).
|
|
324
|
+
* What keeps it is the Run loop asking the Process Table again each tick and dropping a Lane that
|
|
325
|
+
* is not clean (`contendedNow`, core/run.ts). This is the cheap first cut: an Account that is
|
|
326
|
+
* plainly busy never becomes a Lane at all, and the one line about it is written once at start
|
|
327
|
+
* rather than on every tick.
|
|
328
|
+
*/
|
|
329
|
+
export function failoverAccountsFor(shared: SharedDeps, account: Account, local: ForemanLocal | undefined, isAlive: (pid: number | undefined) => boolean, now: () => number = Date.now): Account[] {
|
|
330
|
+
const siblings = (shared.store?.load() ?? []).filter((a) => a.name !== account.name && a.vendor === account.vendor);
|
|
331
|
+
return uncontended(shared, notQuarantined(shared, siblings, "Failover"), local, isAlive, now, "Failover");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The Accounts a *pinned* Task may be routed to that Failover would never reach (story 24): every
|
|
336
|
+
* registered Account of another vendor, minus the ones another Foreman is already running Workers
|
|
337
|
+
* under. They become pin-only Lanes inside the Run (`RunDeps.pinnable`, core/run.ts).
|
|
338
|
+
*
|
|
339
|
+
* Vendor is the whole difference from `failoverAccountsFor`, and it is the difference that makes
|
|
340
|
+
* them two functions rather than one: Failover moves work the Foreman chose to move, and moving a
|
|
341
|
+
* Task between vendors is not the same work moved (the session, the prompt contract and the adapter
|
|
342
|
+
* are the vendor's), so it stays within one. A pin is the developer choosing, having said which
|
|
343
|
+
* vendor they meant — which is the entire point of story 24 — so it crosses.
|
|
344
|
+
*
|
|
345
|
+
* The contention filter is the same one and for the same reason (ADR-0013): an Account another
|
|
346
|
+
* Foreman has Workers on is one this Run must not open Slots of its own on, whatever the reason it
|
|
347
|
+
* wanted to. A pinned Task whose only Lane is dropped here waits, and is told so.
|
|
348
|
+
*/
|
|
349
|
+
export function pinnableAccountsFor(shared: SharedDeps, account: Account, local: ForemanLocal | undefined, isAlive: (pid: number | undefined) => boolean, now: () => number = Date.now): Account[] {
|
|
350
|
+
const others = (shared.store?.load() ?? []).filter((a) => a.name !== account.name && a.vendor !== account.vendor);
|
|
351
|
+
return uncontended(shared, notQuarantined(shared, others, "a pinned Task"), local, isAlive, now, "a pinned Task");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Drops the quarantined Accounts, saying so once each (OPS-288).
|
|
356
|
+
*
|
|
357
|
+
* The cheap first cut, exactly as the contention filter beside it is: a Lane the Run never builds
|
|
358
|
+
* costs nothing to skip every tick. It is not what keeps a quarantined Account out — `quarantinedNow`
|
|
359
|
+
* inside the Run re-reads the store each tick, and has to, because the quarantine that matters most
|
|
360
|
+
* is the one a turn of this very Run just wrote. What this buys is the line at start, which is where
|
|
361
|
+
* an operator looks to see why the fleet is narrower than the Accounts they registered.
|
|
362
|
+
*/
|
|
363
|
+
function notQuarantined(shared: SharedDeps, accounts: Account[], use: string): Account[] {
|
|
364
|
+
return accounts.filter((a) => {
|
|
365
|
+
if (!a.quarantined) return true;
|
|
366
|
+
shared.out(
|
|
367
|
+
`run: Account "${a.name}" is quarantined (${printableOneLine(a.quarantined.reason)}), so it is not used for ${use}. ` +
|
|
368
|
+
`Log the vendor back in, then: opsee foreman account resume ${a.name}`,
|
|
369
|
+
);
|
|
370
|
+
return false;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Drops the Accounts another Foreman has live Workers on, saying so once per Account. */
|
|
375
|
+
function uncontended(shared: SharedDeps, accounts: Account[], local: ForemanLocal | undefined, isAlive: (pid: number | undefined) => boolean, now: () => number, use: string): Account[] {
|
|
376
|
+
if (!local) return accounts;
|
|
377
|
+
const at = now();
|
|
378
|
+
return accounts.filter((a) => {
|
|
379
|
+
const live = otherForemanWorkersOn(local.table, a.name, at, { isAlive });
|
|
380
|
+
if (live.length === 0) return true;
|
|
381
|
+
shared.out(`run: Account "${a.name}" has ${count(live.length, "Worker")} running under another Foreman, so it is not used for ${use}; its own Foreman keeps its cap`);
|
|
382
|
+
return false;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The code host behind the checkout's origin, for the Hand-off. */
|
|
387
|
+
async function remoteFor(deps: SharedDeps, git: GitRunner, repoRoot: string): Promise<RemoteApi> {
|
|
388
|
+
let originUrl: string;
|
|
389
|
+
try {
|
|
390
|
+
originUrl = await git(["remote", "get-url", "origin"], repoRoot);
|
|
391
|
+
} catch {
|
|
392
|
+
throw new ForemanError("This repository has no origin remote. The Foreman hands work off as a draft pull request on origin, so it needs one (ADR-0003).");
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
return (deps.remoteFor ?? ((url, root) => remoteApiFor(url, runCli, root)))(originUrl, repoRoot);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
throw new ForemanError(`Cannot hand off from this repository: ${error instanceof Error ? error.message : String(error)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The Run's tally, and the comment it leaves on the Initiative, both live in core beside the
|
|
402
|
+
* Run itself (`core/summary.ts`): the counting has one owner, and a terminal line that disagreed
|
|
403
|
+
* with the Initiative comment about how many Tasks need a human would be worse than either alone.
|
|
404
|
+
* Re-exported here because this is where every caller and test already looks for it. */
|
|
405
|
+
export { summarize };
|
|
406
|
+
|
|
407
|
+
/** What a Reconcile pass produced, in the shape of a Run's outcome. */
|
|
408
|
+
export function reconciled(results: Array<Dispatch | BlockedTask>): RunOutcome {
|
|
409
|
+
const outcome: RunOutcome = { dispatches: [], blocked: [], failures: [] };
|
|
410
|
+
for (const r of results) {
|
|
411
|
+
if ("missing" in r) outcome.blocked.push(r);
|
|
412
|
+
else outcome.dispatches.push(r);
|
|
413
|
+
}
|
|
414
|
+
return outcome;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** `opsee foreman run <initiativeId>` (story 14): a Run scoped to that Initiative. With a daemon
|
|
418
|
+
* up (its pid file names a live process) the request is queued for it and this returns at once;
|
|
419
|
+
* otherwise the Run happens in the foreground, until no Ready Task remains. Exit 0 when every
|
|
420
|
+
* dispatched Task was handed off (reported `done` with its draft pull request open) and no Ready
|
|
421
|
+
* Task was blocked on its work contract, 1 otherwise (blocked, failed, stalled, nothing to
|
|
422
|
+
* push...), so a script can tell the two apart. The code host is chosen from origin before the
|
|
423
|
+
* first dispatch: a repository the Foreman could never hand off from is refused up front. */
|
|
424
|
+
export async function runForemanRun(deps: ForemanRunDeps, args: ForemanRunArgs): Promise<number> {
|
|
425
|
+
const account = chooseAccount(deps.store.load(), args.account);
|
|
426
|
+
const repoRoot = await deps.repoRoot();
|
|
427
|
+
if (!repoRoot) throw new ForemanError("Not inside a git repository; run foreman run from the checkout the Initiative's Tasks are about.");
|
|
428
|
+
|
|
429
|
+
const daemon = deps.local ? liveDaemonPid(deps.local.pidFile, deps.local.probe) : undefined;
|
|
430
|
+
if (daemon !== undefined && deps.local) {
|
|
431
|
+
const request = deps.local.table.requestRun({
|
|
432
|
+
initiativeId: args.initiativeId,
|
|
433
|
+
account: account.name,
|
|
434
|
+
once: args.once,
|
|
435
|
+
taskId: args.taskId,
|
|
436
|
+
repoRoot,
|
|
437
|
+
maxTurns: args.maxTurns,
|
|
438
|
+
stallTimeoutMs: args.stallTimeoutMs,
|
|
439
|
+
});
|
|
440
|
+
deps.out(`Run #${request.id} queued for the Foreman daemon (pid ${daemon}) on Account "${account.name}"; it starts on the daemon's next tick and its output goes to the daemon's terminal.`);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// In the foreground this process is the one running the Workers, so it is the one that must not
|
|
445
|
+
// double the Account's cap on top of another Foreman's.
|
|
446
|
+
if (deps.local) refuseSecondRunOnAccount(deps.local.table, account, deps.isAlive ?? pidIsAlive);
|
|
447
|
+
refuseQuarantinedRun(account);
|
|
448
|
+
|
|
449
|
+
deps.out(`repository: ${repoRoot}`);
|
|
450
|
+
const learningsConfig = checkLearningsFile(repoRoot);
|
|
451
|
+
if (learningsConfig.refused) deps.out(`learnings: ${learningsConfig.refused}`);
|
|
452
|
+
// In the foreground there is no daemon to hand a paused Run back to, so it waits for the resume.
|
|
453
|
+
const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, deps.local, { maxTurns: args.maxTurns, stallTimeoutMs: args.stallTimeoutMs }), {
|
|
454
|
+
initiativeId: args.initiativeId,
|
|
455
|
+
once: args.once,
|
|
456
|
+
taskId: args.taskId,
|
|
457
|
+
waitWhilePaused: true,
|
|
458
|
+
});
|
|
459
|
+
const { line, needingAttention, blocked } = summarize(outcome);
|
|
460
|
+
deps.out(`Run over: ${line}`);
|
|
461
|
+
return needingAttention === 0 && blocked === 0 ? 0 : 1;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The repository the current directory is in, for the real command. */
|
|
465
|
+
export const currentRepoRoot = () => repoRootOf(process.cwd());
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { Client } from "@connectrpc/connect";
|
|
4
|
+
import type { ProjectService } from "@opsee/mcp-server/gen/api/v1/project_pb.js";
|
|
5
|
+
import type { LabelService } from "@opsee/mcp-server/gen/api/v1/label_pb.js";
|
|
6
|
+
import type { BoardService } from "@opsee/mcp-server/gen/api/v1/board_pb.js";
|
|
7
|
+
import type { BoardColumnService } from "@opsee/mcp-server/gen/api/v1/board_column_pb.js";
|
|
8
|
+
import type { TaskTypeService } from "@opsee/mcp-server/gen/api/v1/task_type_pb.js";
|
|
9
|
+
import type { TaskPriorityService } from "@opsee/mcp-server/gen/api/v1/task_priority_pb.js";
|
|
10
|
+
import { planWrite, type WriteAction, type WritePlan } from "../init/managed.js";
|
|
11
|
+
import { mergeClaudeMcpJson, mergeCodexConfigToml } from "../init/mcp-config.js";
|
|
12
|
+
import { pickInstructionFile, pointerBlock, upsertPointerBlock } from "../init/pointer-block.js";
|
|
13
|
+
import { CLAUDE_MCP_PATH, CODEX_CONFIG_PATH, SKILL_TARGETS, TRACKER_DOC_PATH } from "../init/paths.js";
|
|
14
|
+
import { pickProject, type ProjectChoice } from "../init/project.js";
|
|
15
|
+
import { listSkillFiles, skillNames } from "../init/skills.js";
|
|
16
|
+
import { findReadyLabel, READY_LABEL, renderTrackerDoc, type Named, type TrackerDocData } from "../init/tracker-doc.js";
|
|
17
|
+
import { existingRecipe, inferRecipe, mergeRecipeJson, mergeRecipeYaml, NO_START_COMMAND, type RecipeDefaults, type RecipeFlags } from "../init/run-recipe-config.js";
|
|
18
|
+
import { OPSEE_CONFIG_JSON, OPSEE_CONFIG_YAML, readOpseeConfigFiles } from "../opsee-config.js";
|
|
19
|
+
import type { RunRecipe } from "../foreman/run-recipe.js";
|
|
20
|
+
import { NOT_LOGGED_IN } from "./whoami.js";
|
|
21
|
+
import type { CommandDeps } from "./deps.js";
|
|
22
|
+
|
|
23
|
+
/** The slices of the generated clients `init` reads; tests supply fakes. All reads: setting a
|
|
24
|
+
* repo up never writes to the tracker. */
|
|
25
|
+
export interface InitApi {
|
|
26
|
+
projects: Pick<Client<typeof ProjectService>, "getProjects">;
|
|
27
|
+
labels: Pick<Client<typeof LabelService>, "getLabels">;
|
|
28
|
+
boards: Pick<Client<typeof BoardService>, "getBoards">;
|
|
29
|
+
boardColumns: Pick<Client<typeof BoardColumnService>, "getBoardColumns">;
|
|
30
|
+
taskTypes: Pick<Client<typeof TaskTypeService>, "getTaskTypes">;
|
|
31
|
+
taskPriorities: Pick<Client<typeof TaskPriorityService>, "getTaskPriorities">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InitDeps extends Pick<CommandDeps, "isAuthenticated" | "out"> {
|
|
35
|
+
api: InitApi;
|
|
36
|
+
/** The repo to set up; `init` writes only under it. */
|
|
37
|
+
root: string;
|
|
38
|
+
/** `--project <key>`; when absent and the account has several projects, `choose` is asked. */
|
|
39
|
+
projectKey?: string;
|
|
40
|
+
/** Interactive picker; resolves null when there is no terminal to ask on. */
|
|
41
|
+
choose: (projects: ProjectChoice[]) => Promise<string | null>;
|
|
42
|
+
mcpUrl: string;
|
|
43
|
+
skillsDir: string;
|
|
44
|
+
trackerTemplate: string;
|
|
45
|
+
/** Run Recipe values given on the command line; the rest is inferred or asked for. */
|
|
46
|
+
recipeFlags: RecipeFlags;
|
|
47
|
+
/** Asks for the Run Recipe with inferred defaults; null when there is no start command to
|
|
48
|
+
* write. Only called when `.opsee/config` has no `foreman` block yet. */
|
|
49
|
+
askRecipe: (defaults: RecipeDefaults) => Promise<RunRecipe | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const CODEX_TRUST_HINT =
|
|
53
|
+
"Codex reads .codex/config.toml only in a trusted project: open the repo in Codex once and accept its trust prompt.";
|
|
54
|
+
|
|
55
|
+
/** Sets a repo up for Opsee-driven planning: skills, tracker doc, MCP registration, and the
|
|
56
|
+
* instructions pointer. Re-runs update what Opsee wrote and leave anything else alone. Returns
|
|
57
|
+
* the exit code. */
|
|
58
|
+
export async function runInit(deps: InitDeps): Promise<number> {
|
|
59
|
+
if (!deps.isAuthenticated()) {
|
|
60
|
+
deps.out(NOT_LOGGED_IN);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const res = await deps.api.projects.getProjects({});
|
|
64
|
+
const project = await pickProject(
|
|
65
|
+
res.projects.filter((p) => p.isActive).map((p) => ({ id: p.id, key: p.shortName, name: p.name })),
|
|
66
|
+
deps.projectKey,
|
|
67
|
+
deps.choose,
|
|
68
|
+
);
|
|
69
|
+
if (typeof project === "string") {
|
|
70
|
+
deps.out(project);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const data = await collect(deps.api, project);
|
|
74
|
+
const doc = renderTrackerDoc(deps.trackerTemplate, data);
|
|
75
|
+
const skills = listSkillFiles(deps.skillsDir);
|
|
76
|
+
|
|
77
|
+
const report = new Reporter(deps.root, deps.out);
|
|
78
|
+
for (const target of SKILL_TARGETS) {
|
|
79
|
+
for (const file of skills) {
|
|
80
|
+
const rel = join(target, file.rel);
|
|
81
|
+
report.apply(rel, planWrite(read(deps.root, rel), file.content, file.style));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
report.apply(TRACKER_DOC_PATH, planWrite(read(deps.root, TRACKER_DOC_PATH), doc, "markdown"));
|
|
85
|
+
report.apply(CLAUDE_MCP_PATH, mergeClaudeMcpJson(read(deps.root, CLAUDE_MCP_PATH), deps.mcpUrl));
|
|
86
|
+
report.apply(CODEX_CONFIG_PATH, mergeCodexConfigToml(read(deps.root, CODEX_CONFIG_PATH), deps.mcpUrl));
|
|
87
|
+
const instructions = pickInstructionFile((name) => existsSync(join(deps.root, name)));
|
|
88
|
+
report.apply(instructions, upsertPointerBlock(read(deps.root, instructions), pointerBlock(TRACKER_DOC_PATH)));
|
|
89
|
+
|
|
90
|
+
// The Run Recipe (spec story 5): written once into the analyzer's config, in both of its forms
|
|
91
|
+
// when both exist, and never rewritten while a foreman block is there. A block present in only
|
|
92
|
+
// one of the two files is copied into the other, so they stay in step without a prompt.
|
|
93
|
+
const config = readOpseeConfigFiles(deps.root);
|
|
94
|
+
const answers = existingRecipe(config) ?? (await deps.askRecipe(inferRecipe(config, deps.recipeFlags)));
|
|
95
|
+
if (answers === null) {
|
|
96
|
+
deps.out(`skipped ${OPSEE_CONFIG_YAML} — ${NO_START_COMMAND}`);
|
|
97
|
+
} else {
|
|
98
|
+
report.apply(OPSEE_CONFIG_YAML, mergeRecipeYaml(config.yaml, answers));
|
|
99
|
+
if (config.json !== null || config.yaml === null) report.apply(OPSEE_CONFIG_JSON, mergeRecipeJson(config.json, answers));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
deps.out("");
|
|
103
|
+
deps.out(`Project ${project.name} (${project.key}, id ${project.id}); skills ${skillNames(skills).map((s) => `/${s}`).join(", ")}.`);
|
|
104
|
+
if (!findReadyLabel(data.labels)) {
|
|
105
|
+
deps.out(`Project ${project.key} has no ${READY_LABEL} label, the dispatch signal the skills rely on. Create it in Opsee and re-run opsee init to fill in its id.`);
|
|
106
|
+
}
|
|
107
|
+
deps.out(CODEX_TRUST_HINT);
|
|
108
|
+
deps.out(report.summary());
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Everything the tracker doc names by id. */
|
|
113
|
+
async function collect(api: InitApi, project: ProjectChoice): Promise<TrackerDocData> {
|
|
114
|
+
const projectId = project.id;
|
|
115
|
+
const named = (items: Array<{ id: number; name: string }>): Named[] => items.map((i) => ({ id: i.id, name: i.name }));
|
|
116
|
+
const boards = (await api.boards.getBoards({ projectId })).boards
|
|
117
|
+
.filter((b) => b.isActive)
|
|
118
|
+
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
119
|
+
const board = boards.find((b) => /main/i.test(b.name)) ?? boards[0];
|
|
120
|
+
if (!board) throw new Error(`Project ${project.key} has no board`);
|
|
121
|
+
const columns = (await api.boardColumns.getBoardColumns({ boardId: board.id })).boardColumns
|
|
122
|
+
.filter((c) => c.isActive)
|
|
123
|
+
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
124
|
+
const types = (await api.taskTypes.getTaskTypes({ projectId })).taskTypes
|
|
125
|
+
.filter((t) => t.isActive)
|
|
126
|
+
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
127
|
+
const priorities = (await api.taskPriorities.getTaskPriorities({ projectId })).taskPriorities
|
|
128
|
+
.filter((p) => p.isActive)
|
|
129
|
+
.sort((a, b) => a.level - b.level);
|
|
130
|
+
const labels = (await api.labels.getLabels({ projectId })).labels.filter((l) => l.isActive);
|
|
131
|
+
return {
|
|
132
|
+
project: { id: projectId, name: project.name, key: project.key },
|
|
133
|
+
labels: named(labels),
|
|
134
|
+
board: { id: board.id, name: board.name },
|
|
135
|
+
columns: named(columns),
|
|
136
|
+
types: named(types),
|
|
137
|
+
priorities: named(priorities),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function read(root: string, rel: string): string | null {
|
|
142
|
+
const path = join(root, rel);
|
|
143
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const EXPLAIN: Partial<Record<WriteAction, string>> = {
|
|
147
|
+
"kept-edited": "edited since opsee init wrote it; delete it to let opsee init write its own",
|
|
148
|
+
"kept-unmanaged": "not written by opsee init; delete it to let opsee init write its own",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
class Reporter {
|
|
152
|
+
private counts = new Map<WriteAction, number>();
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
private root: string,
|
|
156
|
+
private out: (line: string) => void,
|
|
157
|
+
) {}
|
|
158
|
+
|
|
159
|
+
apply(rel: string, plan: WritePlan) {
|
|
160
|
+
if (plan.content !== undefined && (plan.action === "create" || plan.action === "update")) {
|
|
161
|
+
const path = join(this.root, rel);
|
|
162
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
163
|
+
writeFileSync(path, plan.content);
|
|
164
|
+
}
|
|
165
|
+
this.counts.set(plan.action, (this.counts.get(plan.action) ?? 0) + 1);
|
|
166
|
+
const why = plan.action === "kept-invalid" ? `${plan.reason}` : EXPLAIN[plan.action];
|
|
167
|
+
const verb = plan.action.startsWith("kept") ? "kept" : plan.action;
|
|
168
|
+
this.out(`${verb.padEnd(9)} ${rel}${why ? ` — ${why}` : ""}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
summary(): string {
|
|
172
|
+
const n = (a: WriteAction) => this.counts.get(a) ?? 0;
|
|
173
|
+
const kept = n("kept-edited") + n("kept-unmanaged") + n("kept-invalid");
|
|
174
|
+
return `${n("create")} created, ${n("update")} updated, ${n("unchanged")} unchanged, ${kept} kept.`;
|
|
175
|
+
}
|
|
176
|
+
}
|