@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work contract (see ../../../CONTEXT.md: Goal, Acceptance Criteria, Verification, Boundaries;
|
|
3
|
+
* spec story 23): the headings a Ready Task's description carries, validated at dispatch. Goal and
|
|
4
|
+
* Acceptance Criteria are what a Worker implements against and cannot be invented, so a Task missing
|
|
5
|
+
* either is blocked with a comment saying what is absent. Verification is what the Verifier follows
|
|
6
|
+
* and can be drafted from the other two, so a Task missing only that gets a Triage turn (triage.ts)
|
|
7
|
+
* whose draft is posted as a comment; the comment is the record the Verifier slice reads back with
|
|
8
|
+
* `draftedVerificationFrom`. Boundaries is optional. Nothing here edits a Task description.
|
|
9
|
+
*
|
|
10
|
+
* Everything in this module is pure; run.ts calls it before the dispatch event.
|
|
11
|
+
*/
|
|
12
|
+
import { DISPATCH_LABEL, type TrackerComment } from "../tracker-adapter.js";
|
|
13
|
+
|
|
14
|
+
export const WORK_CONTRACT_SECTIONS = ["Goal", "Acceptance Criteria", "Verification", "Boundaries"] as const;
|
|
15
|
+
export type WorkContractSection = (typeof WORK_CONTRACT_SECTIONS)[number];
|
|
16
|
+
|
|
17
|
+
/** The sections a Task must carry to be dispatched; Boundaries is optional. */
|
|
18
|
+
export const REQUIRED_SECTIONS: readonly WorkContractSection[] = ["Goal", "Acceptance Criteria", "Verification"];
|
|
19
|
+
|
|
20
|
+
export interface WorkContract {
|
|
21
|
+
/** Each present section's body, trimmed, without its heading; a section with nothing under its
|
|
22
|
+
* heading is absent. */
|
|
23
|
+
sections: Partial<Record<WorkContractSection, string>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the check at dispatch found (not a Verdict, which is the Verifier's word). */
|
|
27
|
+
export type WorkContractCheck =
|
|
28
|
+
| { status: "ok" }
|
|
29
|
+
/** Goal and Acceptance Criteria are there; Verification is the only required section missing. */
|
|
30
|
+
| { status: "needs_triage"; missing: ["Verification"] }
|
|
31
|
+
/** Goal or Acceptance Criteria is missing: every absent required section, in canonical order. */
|
|
32
|
+
| { status: "blocked"; missing: WorkContractSection[] };
|
|
33
|
+
|
|
34
|
+
const HEADING = /^ {0,3}(#{1,6})\s+(.+?)\s*$/;
|
|
35
|
+
const FENCE = /^ {0,3}(```|~~~)/;
|
|
36
|
+
|
|
37
|
+
/** `## Acceptance criteria:` and `### VERIFICATION ##` both name their section. */
|
|
38
|
+
function sectionOf(headingText: string): WorkContractSection | undefined {
|
|
39
|
+
const name = headingText
|
|
40
|
+
.replace(/\s*#+\s*$/, "")
|
|
41
|
+
.replace(/\s*:+\s*$/, "")
|
|
42
|
+
.replace(/\s+/g, " ")
|
|
43
|
+
.trim()
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
return WORK_CONTRACT_SECTIONS.find((s) => s.toLowerCase() === name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Which of the four sections the description (markdown; the Tracker Adapter converts its storage
|
|
49
|
+
* format) carries. A heading counts only with something under it: an empty section is a
|
|
50
|
+
* placeholder, not a contract. Headings inside fenced code are content. */
|
|
51
|
+
export function parseWorkContract(description: string): WorkContract {
|
|
52
|
+
const bodies = new Map<WorkContractSection, string[]>();
|
|
53
|
+
let current: WorkContractSection | undefined;
|
|
54
|
+
let currentLevel = 0;
|
|
55
|
+
let inFence = false;
|
|
56
|
+
for (const line of description.split("\n")) {
|
|
57
|
+
if (FENCE.test(line)) {
|
|
58
|
+
inFence = !inFence;
|
|
59
|
+
} else if (!inFence) {
|
|
60
|
+
const heading = HEADING.exec(line);
|
|
61
|
+
if (heading) {
|
|
62
|
+
const level = heading[1].length;
|
|
63
|
+
const named = sectionOf(heading[2]);
|
|
64
|
+
// A section runs until a heading at its own level or above, or one naming another
|
|
65
|
+
// section: `### Commands` under `## Verification` is part of Verification, not its end.
|
|
66
|
+
if (named) {
|
|
67
|
+
current = named;
|
|
68
|
+
currentLevel = level;
|
|
69
|
+
if (!bodies.has(current)) bodies.set(current, []);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (current && level > currentLevel) {
|
|
73
|
+
bodies.get(current)!.push(line);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
current = undefined;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (current) bodies.get(current)!.push(line);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sections: Partial<Record<WorkContractSection, string>> = {};
|
|
84
|
+
for (const [section, lines] of bodies) {
|
|
85
|
+
const body = lines.join("\n").trim();
|
|
86
|
+
if (body) sections[section] = body;
|
|
87
|
+
}
|
|
88
|
+
return { sections };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateWorkContract(contract: WorkContract): WorkContractCheck {
|
|
92
|
+
const missing = REQUIRED_SECTIONS.filter((s) => contract.sections[s] === undefined);
|
|
93
|
+
if (missing.length === 0) return { status: "ok" };
|
|
94
|
+
if (missing.length === 1 && missing[0] === "Verification") return { status: "needs_triage", missing: ["Verification"] };
|
|
95
|
+
return { status: "blocked", missing };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** First line of the comment a Triage turn leaves; what `draftedVerificationFrom` looks for. */
|
|
99
|
+
export const TRIAGE_COMMENT_MARKER = "Foreman: drafted Verification section (Triage turn)";
|
|
100
|
+
|
|
101
|
+
const DRAFT_HEADING = "## Verification";
|
|
102
|
+
|
|
103
|
+
/** The comment carrying a drafted Verification section: the marker, then the section as it would
|
|
104
|
+
* read in the description, so a human can paste it there. */
|
|
105
|
+
export function triageComment(verification: string): string {
|
|
106
|
+
return `${TRIAGE_COMMENT_MARKER}\n\n${DRAFT_HEADING}\n\n${verification.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DraftedVerification {
|
|
110
|
+
/** The comment the draft was read from. */
|
|
111
|
+
commentId: number;
|
|
112
|
+
verification: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The newest Verification section the Foreman drafted on a Task, from its comments; what a later
|
|
116
|
+
* dispatch reuses instead of running another Triage turn, and what the Verifier follows. */
|
|
117
|
+
export function draftedVerificationFrom(comments: Pick<TrackerComment, "id" | "body">[]): DraftedVerification | undefined {
|
|
118
|
+
const drafts = comments
|
|
119
|
+
.filter((c) => c.body.startsWith(TRIAGE_COMMENT_MARKER))
|
|
120
|
+
.sort((a, b) => b.id - a.id);
|
|
121
|
+
for (const draft of drafts) {
|
|
122
|
+
const rest = draft.body.slice(TRIAGE_COMMENT_MARKER.length);
|
|
123
|
+
const at = rest.indexOf(DRAFT_HEADING);
|
|
124
|
+
const verification = (at === -1 ? rest : rest.slice(at + DRAFT_HEADING.length)).trim();
|
|
125
|
+
if (verification) return { commentId: draft.id, verification };
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RELABEL = `The "${DISPATCH_LABEL}" label has been removed; add the missing sections to the description and re-add the label.`;
|
|
131
|
+
|
|
132
|
+
/** The comment on a Task the Foreman will not dispatch. The dispatch label comes off with it, or
|
|
133
|
+
* the Task would be picked again every tick; a human re-adds it after fixing the description. */
|
|
134
|
+
export function blockedComment(missing: readonly WorkContractSection[]): string {
|
|
135
|
+
return `Foreman: cannot dispatch, the work contract is missing: ${missing.join(", ")}. ${RELABEL}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The comment when the Triage turn produced no draft: the section is still missing, and why. */
|
|
139
|
+
export function triageFailedComment(reason: string): string {
|
|
140
|
+
return `Foreman: cannot dispatch, the work contract is missing: Verification, and the Triage turn drafted nothing (${reason}). ${RELABEL}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The comment when every Triage turn the retry cap allows ended in a machine-side failure — a
|
|
144
|
+
* stall, a rate limit — rather than in a draft. Names the cause that spent the attempts, the way a
|
|
145
|
+
* capped dispatch does, so a human reading the Task alone knows whether to retry it or to fix
|
|
146
|
+
* something first. */
|
|
147
|
+
export function triageCappedComment(outcome: string, reason: string, attempts: number, retries: number): string {
|
|
148
|
+
return (
|
|
149
|
+
`Foreman: cannot dispatch, the work contract is missing: Verification, and every Triage turn allowed for drafting it ended ${outcome} (${reason}).` +
|
|
150
|
+
` ${attempts} ${attempts === 1 ? "attempt" : "attempts"} ended that way, which is the retry cap (${retries}) reached; nothing about the Task itself is known to be wrong. ${RELABEL}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspaces (see ../../../CONTEXT.md, story 19, ADR-0003): one git worktree per Ready Task, on a
|
|
3
|
+
* branch named after the Task identifier, created from the default branch's remote head.
|
|
4
|
+
*
|
|
5
|
+
* Where they live: `~/.opsee/workspaces/<repo>-<hash>/<identifier>` (or under
|
|
6
|
+
* `OPSEE_FOREMAN_WORKSPACES_PATH`), never inside the repository. Inside it a Workspace would show
|
|
7
|
+
* up in the main checkout's `git status` (the Foreman must leave that checkout untouched, story 22)
|
|
8
|
+
* and would sit in a directory something cleans; a Workspace outlives its turn until the Hand-off
|
|
9
|
+
* is merged or closed (ADR-0003), so it belongs beside the Foreman's other durable state, the
|
|
10
|
+
* Accounts file. The hash is of the repository's resolved root, so two checkouts of the same repo
|
|
11
|
+
* (or two repos with the same name) never share a Workspace.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, join, resolve } from "node:path";
|
|
17
|
+
import { execToString } from "./exec.js";
|
|
18
|
+
|
|
19
|
+
export interface Workspace {
|
|
20
|
+
/** Absolute path of the worktree: the Worker's cwd and nothing else. */
|
|
21
|
+
path: string;
|
|
22
|
+
branch: string;
|
|
23
|
+
/** False when the Workspace already existed (an earlier attempt on the same Task). */
|
|
24
|
+
created: boolean;
|
|
25
|
+
/** What the branch was created from, when it was created here. */
|
|
26
|
+
base?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runs one git command in `cwd` and resolves with trimmed stdout; rejects on a non-zero exit with
|
|
30
|
+
* stderr in the message. The seam the tests replace to script git without a repository. */
|
|
31
|
+
export type GitRunner = (args: string[], cwd: string) => Promise<string>;
|
|
32
|
+
|
|
33
|
+
export const runGit: GitRunner = async (args, cwd) => (await execToString("git", args, cwd, `git ${args.join(" ")}`)).trim();
|
|
34
|
+
|
|
35
|
+
/** The name of the default branch on `origin` (`development`, `main`...): what `origin/HEAD` points
|
|
36
|
+
* at, else the first of the usual names that exists on origin; undefined when none can be found.
|
|
37
|
+
* Workspaces start from it and the Hand-off targets it, so both read it from one place. */
|
|
38
|
+
export async function defaultBranchOf(git: GitRunner, repoRoot: string): Promise<string | undefined> {
|
|
39
|
+
try {
|
|
40
|
+
const ref = await git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repoRoot);
|
|
41
|
+
return ref.replace(/^origin\//, "");
|
|
42
|
+
} catch {
|
|
43
|
+
/* origin/HEAD unset: a clone made before the remote had a HEAD, or a bare-remote setup */
|
|
44
|
+
}
|
|
45
|
+
for (const candidate of ["development", "main", "master"]) {
|
|
46
|
+
try {
|
|
47
|
+
await git(["rev-parse", "--verify", "--quiet", `origin/${candidate}`], repoRoot);
|
|
48
|
+
return candidate;
|
|
49
|
+
} catch {
|
|
50
|
+
/* not this one */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A git command the Foreman refuses to run: a push to the default branch, or a merge. */
|
|
57
|
+
export class GitGuardError extends Error {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "GitGuardError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The global git options the Foreman itself uses. Any other option before the subcommand is
|
|
65
|
+
* refused rather than skipped: `--namespace x push ...` or `--config-env k=v push ...` would
|
|
66
|
+
* otherwise make the guard read `x` as the subcommand and never look at the push. */
|
|
67
|
+
const GLOBAL_OPTIONS_WITH_VALUE = new Set(["-c", "-C"]);
|
|
68
|
+
const GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
69
|
+
|
|
70
|
+
/** The subcommands the Foreman runs. An alias (`-c alias.p=push p ...`) or anything else is
|
|
71
|
+
* refused: an allow-list cannot be talked around by a spelling the guard does not know. `show`
|
|
72
|
+
* reads a blob of the base (the Gates' recipe, gates.ts); `add` and `commit` write the learnings
|
|
73
|
+
* file on the Foreman's own branch, and `reset` aligns that Workspace with origin before adding to
|
|
74
|
+
* an open pull request (core/learnings.ts); none of them can push or merge. */
|
|
75
|
+
const ALLOWED_SUBCOMMANDS = new Set(["fetch", "push", "worktree", "rev-parse", "rev-list", "status", "remote", "symbolic-ref", "branch", "log", "show", "show-ref", "ls-remote", "config", "add", "commit", "reset"]);
|
|
76
|
+
|
|
77
|
+
/** The git subcommand in `args`, past the global options the Foreman uses. `undefined` when the
|
|
78
|
+
* command line is shaped in a way the guard does not understand; that is refused, not allowed. */
|
|
79
|
+
function subcommandOf(args: string[]): { name: string | undefined; rest: string[] } {
|
|
80
|
+
for (let i = 0; i < args.length; i++) {
|
|
81
|
+
const arg = args[i];
|
|
82
|
+
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
|
|
83
|
+
if (arg === "-c" && /^alias\./i.test(args[i + 1] ?? "")) return { name: undefined, rest: [] };
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (GLOBAL_FLAGS.has(arg)) continue;
|
|
88
|
+
if (arg.startsWith("-")) return { name: undefined, rest: [] };
|
|
89
|
+
return { name: arg, rest: args.slice(i + 1) };
|
|
90
|
+
}
|
|
91
|
+
return { name: undefined, rest: [] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A push option the Foreman never sends and a remote could act on: on GitLab `merge_request.*`
|
|
95
|
+
* options create, retarget, or schedule the merge of an MR from the push itself. */
|
|
96
|
+
function pushOptionsOf(rest: string[]): string[] {
|
|
97
|
+
const options: string[] = [];
|
|
98
|
+
for (let i = 0; i < rest.length; i++) {
|
|
99
|
+
const arg = rest[i];
|
|
100
|
+
if (arg === "-o" || arg === "--push-option") options.push(rest[++i] ?? "");
|
|
101
|
+
else if (arg.startsWith("--push-option=")) options.push(arg.slice("--push-option=".length));
|
|
102
|
+
else if (arg.startsWith("-o") && arg.length > 2) options.push(arg.slice(2));
|
|
103
|
+
}
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Push options that take their value as the next argument, so the value is not read as the remote
|
|
108
|
+
* or a refspec (`-o reviewer=x origin a:b` would otherwise shift the parse by one). */
|
|
109
|
+
const PUSH_OPTIONS_WITH_VALUE = new Set(["-o", "--push-option", "--receive-pack", "--exec", "--repo", "--recurse-submodules", "--signed", "--force-if-includes"]);
|
|
110
|
+
|
|
111
|
+
/** What a `git push` argument list would write on the remote: the destination of every refspec,
|
|
112
|
+
* or `undefined` for a push whose targets git alone would decide (no refspec, `--all`, `--mirror`).
|
|
113
|
+
* Destinations are bare branch names, `HEAD` for the remote's HEAD. */
|
|
114
|
+
function pushDestinations(rest: string[]): string[] | undefined {
|
|
115
|
+
const positional: string[] = [];
|
|
116
|
+
let del = false;
|
|
117
|
+
let everything = false;
|
|
118
|
+
for (let i = 0; i < rest.length; i++) {
|
|
119
|
+
const arg = rest[i];
|
|
120
|
+
if (arg === "--delete" || arg === "-d") del = true;
|
|
121
|
+
else if (arg === "--all" || arg === "--mirror" || arg === "--branches" || arg === "--tags") everything = true;
|
|
122
|
+
else if (PUSH_OPTIONS_WITH_VALUE.has(arg)) i++;
|
|
123
|
+
else if (!arg.startsWith("-")) positional.push(arg);
|
|
124
|
+
}
|
|
125
|
+
if (everything) return undefined;
|
|
126
|
+
const refspecs = positional.slice(1);
|
|
127
|
+
if (refspecs.length === 0) return undefined;
|
|
128
|
+
return refspecs.map((spec) => {
|
|
129
|
+
const dst = del ? spec : spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
|
|
130
|
+
return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Wraps a GitRunner so the two things the Foreman must never do cannot happen by accident
|
|
135
|
+
* (ADR-0003, story 26): a `push` that would update the default branch on origin (by name, by the
|
|
136
|
+
* remote's `HEAD`, by deletion, or by pushing everything) is refused, and so is every `merge` and
|
|
137
|
+
* `pull`. Only the remote's default branch name is origin's; a refspec is judged by its destination
|
|
138
|
+
* whatever remote it names, so `push upstream x:development` is refused as well. The
|
|
139
|
+
* default branch is read once per repository the way Workspaces read it. A push with no explicit
|
|
140
|
+
* refspec is refused too: where it lands depends on configuration the guard cannot see. */
|
|
141
|
+
export function guardedGit(inner: GitRunner, repoRoot: string): GitRunner {
|
|
142
|
+
let defaultBranch: Promise<string | undefined> | undefined;
|
|
143
|
+
return async (args, cwd) => {
|
|
144
|
+
const { name, rest } = subcommandOf(args);
|
|
145
|
+
// `pull` is a fetch plus a merge (or a rebase) into the checked-out branch: a merge by another name.
|
|
146
|
+
if (name === "merge" || name === "pull") {
|
|
147
|
+
throw new GitGuardError(`refused: git ${args.join(" ")}. The Foreman never merges; work leaves a Workspace only as a draft pull request (ADR-0003).`);
|
|
148
|
+
}
|
|
149
|
+
if (name === undefined || !ALLOWED_SUBCOMMANDS.has(name)) {
|
|
150
|
+
throw new GitGuardError(`refused: git ${args.join(" ")}. The Foreman runs a fixed set of git subcommands with no aliases or unknown global options, so the guard can always see a push or a merge (ADR-0003).`);
|
|
151
|
+
}
|
|
152
|
+
if (name === "push") {
|
|
153
|
+
const options = pushOptionsOf(rest);
|
|
154
|
+
if (options.length > 0) {
|
|
155
|
+
throw new GitGuardError(`refused: git ${args.join(" ")}. A Hand-off sends no push options; ${options.map((o) => `"${o}"`).join(", ")} could make the remote create, retarget, or merge a pull request on its own (ADR-0003).`);
|
|
156
|
+
}
|
|
157
|
+
const destinations = pushDestinations(rest);
|
|
158
|
+
if (!destinations) {
|
|
159
|
+
throw new GitGuardError(`refused: git ${args.join(" ")}. A Hand-off pushes one named branch explicitly, never whatever git would choose, so it cannot reach the default branch (ADR-0003).`);
|
|
160
|
+
}
|
|
161
|
+
defaultBranch ??= defaultBranchOf(inner, repoRoot);
|
|
162
|
+
const def = await defaultBranch;
|
|
163
|
+
const hit = destinations.find((d) => d === "HEAD" || (def !== undefined && d === def));
|
|
164
|
+
if (hit) {
|
|
165
|
+
throw new GitGuardError(
|
|
166
|
+
`refused: git ${args.join(" ")}. It would write ${hit === "HEAD" ? "the remote's HEAD" : `the default branch "${def}"`} on origin; the Foreman never pushes to the default branch, the Hand-off is a draft pull request (ADR-0003).`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return inner(args, cwd);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The branch for a Task: its identifier, lowercased, so `OPS-269` works on `ops-269`. A branch the
|
|
175
|
+
* Foreman names itself (`foreman/learnings-17-...`, core/learnings.ts) may carry `/`-separated
|
|
176
|
+
* segments; each segment is held to the same shape. */
|
|
177
|
+
export function branchFor(identifier: string): string {
|
|
178
|
+
const branch = identifier.trim().toLowerCase();
|
|
179
|
+
if (!/^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/.test(branch)) {
|
|
180
|
+
throw new Error(`Task identifier "${identifier}" does not make a valid branch name`);
|
|
181
|
+
}
|
|
182
|
+
return branch;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function workspacesRoot(): string {
|
|
186
|
+
return process.env.OPSEE_FOREMAN_WORKSPACES_PATH || join(homedir(), ".opsee", "workspaces");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `<basename>-<12 hex of sha256(resolved root)>`: readable, and unique per checkout. */
|
|
190
|
+
export function repoKey(repoRoot: string): string {
|
|
191
|
+
const real = existsSync(repoRoot) ? realpathSync(repoRoot) : resolve(repoRoot);
|
|
192
|
+
const hash = createHash("sha256").update(real).digest("hex").slice(0, 12);
|
|
193
|
+
return `${basename(real)}-${hash}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function workspacePath(repoRoot: string, identifier: string, root: string = workspacesRoot()): string {
|
|
197
|
+
return join(root, repoKey(repoRoot), branchFor(identifier));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface WorkspaceManagerOptions {
|
|
201
|
+
/** Where Workspaces live; `workspacesRoot()` by default. */
|
|
202
|
+
root?: string;
|
|
203
|
+
git?: GitRunner;
|
|
204
|
+
log?: (line: string) => void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Creates and removes Workspaces for one repository checkout. */
|
|
208
|
+
export class WorkspaceManager {
|
|
209
|
+
/** Creations in flight, one at a time; see `create`. */
|
|
210
|
+
private queue: Promise<void> = Promise.resolve();
|
|
211
|
+
private readonly root: string;
|
|
212
|
+
private readonly git: GitRunner;
|
|
213
|
+
private readonly log: (line: string) => void;
|
|
214
|
+
|
|
215
|
+
constructor(
|
|
216
|
+
private readonly repoRoot: string,
|
|
217
|
+
options: WorkspaceManagerOptions = {},
|
|
218
|
+
) {
|
|
219
|
+
this.root = options.root ?? workspacesRoot();
|
|
220
|
+
this.git = options.git ?? runGit;
|
|
221
|
+
this.log = options.log ?? (() => {});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pathFor(identifier: string): string {
|
|
225
|
+
return workspacePath(this.repoRoot, identifier, this.root);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The worktree for `identifier`, created from the default branch's remote head when it does not
|
|
229
|
+
* exist yet. A branch left by an earlier attempt is checked out again rather than recreated, so a
|
|
230
|
+
* Worker resumes where the last one stopped; a branch that exists on origin but not here (pushed
|
|
231
|
+
* from another machine, or the head of a pull request the Foreman is adding to) is checked out
|
|
232
|
+
* from the remote head, tracking it.
|
|
233
|
+
*
|
|
234
|
+
* Creations are taken one at a time. `git worktree add` and `git fetch` write the repository's
|
|
235
|
+
* own bookkeeping — the worktree list, `FETCH_HEAD`, the remote refs — so two of them at once in
|
|
236
|
+
* the same checkout race on the same lock files. Slots (core/scheduler.ts) made this concurrent;
|
|
237
|
+
* queueing here keeps that away from git, and costs nothing worth having, since a creation is
|
|
238
|
+
* seconds and the Worker turn that follows it is not serialised at all.
|
|
239
|
+
*
|
|
240
|
+
* The queue is this manager's, so it holds within one Foreman process and nowhere else: two
|
|
241
|
+
* Foremen on the same checkout still race each other on those same lock files, and what keeps
|
|
242
|
+
* them apart is that only one Foreman runs an Account (commands/foreman.ts) — not this. */
|
|
243
|
+
async create(identifier: string): Promise<Workspace> {
|
|
244
|
+
// A failed creation must not poison the queue for the Slots behind it.
|
|
245
|
+
const mine = this.queue.then(
|
|
246
|
+
() => this.createOne(identifier),
|
|
247
|
+
() => this.createOne(identifier),
|
|
248
|
+
);
|
|
249
|
+
this.queue = mine.then(
|
|
250
|
+
() => undefined,
|
|
251
|
+
() => undefined,
|
|
252
|
+
);
|
|
253
|
+
return mine;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async createOne(identifier: string): Promise<Workspace> {
|
|
257
|
+
const branch = branchFor(identifier);
|
|
258
|
+
const path = this.pathFor(identifier);
|
|
259
|
+
|
|
260
|
+
if (existsSync(path) && (await this.isWorktree(path))) {
|
|
261
|
+
return { path, branch, created: false };
|
|
262
|
+
}
|
|
263
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
264
|
+
|
|
265
|
+
if (await this.branchExists(branch)) {
|
|
266
|
+
this.log(`workspace: branch ${branch} exists; checking it out at ${path}`);
|
|
267
|
+
await this.git(["worktree", "add", path, branch], this.repoRoot);
|
|
268
|
+
return { path, branch, created: true };
|
|
269
|
+
}
|
|
270
|
+
const base = await this.defaultBase();
|
|
271
|
+
if (await this.refExists(`refs/remotes/origin/${branch}`)) {
|
|
272
|
+
this.log(`workspace: branch ${branch} exists on origin; checking it out at ${path}`);
|
|
273
|
+
await this.git(["worktree", "add", "-b", branch, path, `origin/${branch}`], this.repoRoot);
|
|
274
|
+
return { path, branch, created: true, base: `origin/${branch}` };
|
|
275
|
+
}
|
|
276
|
+
this.log(`workspace: creating ${branch} from ${base} at ${path}`);
|
|
277
|
+
await this.git(["worktree", "add", "-b", branch, path, base], this.repoRoot);
|
|
278
|
+
return { path, branch, created: true, base };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The remote head of the default branch (`origin/HEAD`, fetched first), so a Workspace starts
|
|
282
|
+
* from what is merged rather than from whatever the main checkout happens to have checked out.
|
|
283
|
+
* A repository with no remote starts from its own HEAD. */
|
|
284
|
+
private async defaultBase(): Promise<string> {
|
|
285
|
+
let hasOrigin = false;
|
|
286
|
+
try {
|
|
287
|
+
await this.git(["remote", "get-url", "origin"], this.repoRoot);
|
|
288
|
+
hasOrigin = true;
|
|
289
|
+
} catch {
|
|
290
|
+
/* no remote named origin */
|
|
291
|
+
}
|
|
292
|
+
if (!hasOrigin) return "HEAD";
|
|
293
|
+
try {
|
|
294
|
+
await this.git(["fetch", "--quiet", "origin"], this.repoRoot);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.log(`workspace: fetch failed, using the last known remote head (${(error as Error).message})`);
|
|
297
|
+
}
|
|
298
|
+
const branch = await defaultBranchOf(this.git, this.repoRoot);
|
|
299
|
+
return branch ? `origin/${branch}` : "HEAD";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Whether `path` is itself the root of a worktree. `--is-inside-work-tree` alone would also say
|
|
303
|
+
* yes for a plain directory left under some enclosing repository (a home directory tracked as
|
|
304
|
+
* dotfiles, a root pointed inside the checkout), and a Worker would then commit into that. */
|
|
305
|
+
private async isWorktree(path: string): Promise<boolean> {
|
|
306
|
+
try {
|
|
307
|
+
const top = await this.git(["rev-parse", "--show-toplevel"], path);
|
|
308
|
+
return realpathSync(top) === realpathSync(path);
|
|
309
|
+
} catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private branchExists(branch: string): Promise<boolean> {
|
|
315
|
+
return this.refExists(`refs/heads/${branch}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async refExists(ref: string): Promise<boolean> {
|
|
319
|
+
try {
|
|
320
|
+
await this.git(["rev-parse", "--verify", "--quiet", ref], this.repoRoot);
|
|
321
|
+
return true;
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The root of the repository containing `dir`, or undefined when `dir` is not in one. */
|
|
329
|
+
export async function repoRootOf(dir: string, git: GitRunner = runGit): Promise<string | undefined> {
|
|
330
|
+
try {
|
|
331
|
+
return await git(["rev-parse", "--show-toplevel"], dir);
|
|
332
|
+
} catch {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HandOffFn, HandOffInput, HandOffResult } from "./core/handoff.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Hand-off for the Run loop's tests: records what it was asked and, unless told otherwise, hands
|
|
5
|
+
* back a numbered draft pull request on the fake Tracker's repository, the way core/handoff.ts
|
|
6
|
+
* would after a real push. No git, no code host.
|
|
7
|
+
*/
|
|
8
|
+
export function fakeHandOff(result?: (input: HandOffInput, n: number) => HandOffResult) {
|
|
9
|
+
const handOffs: HandOffInput[] = [];
|
|
10
|
+
const handOff: HandOffFn = async (input) => {
|
|
11
|
+
handOffs.push(input);
|
|
12
|
+
const n = handOffs.length;
|
|
13
|
+
if (result) return result(input, n);
|
|
14
|
+
return {
|
|
15
|
+
kind: "handed_off",
|
|
16
|
+
pullRequest: {
|
|
17
|
+
number: n,
|
|
18
|
+
url: `https://remote.example/opsee/monorepo/-/merge_requests/${n}`,
|
|
19
|
+
title: `${input.task.identifier}: ${input.task.title}`,
|
|
20
|
+
draft: true,
|
|
21
|
+
headBranch: input.workspace.branch,
|
|
22
|
+
baseBranch: "development",
|
|
23
|
+
},
|
|
24
|
+
headSha: `sha-${input.workspace.branch}`,
|
|
25
|
+
baseBranch: "development",
|
|
26
|
+
repositoryFullName: "opsee/monorepo",
|
|
27
|
+
reused: false,
|
|
28
|
+
adopted: false,
|
|
29
|
+
commits: 1,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
return { handOff, handOffs };
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LearningsFn, LearningsInput, LearningsResult } from "./core/learnings.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A learnings step for the Run loop's tests: records what it was asked and, unless told otherwise,
|
|
5
|
+
* answers the way core/learnings.ts would after a real push: `none` for an empty batch, else a
|
|
6
|
+
* draft pull request numbered after the batch. No git, no code host.
|
|
7
|
+
*/
|
|
8
|
+
export function fakeLearnings(result?: (input: LearningsInput, n: number) => LearningsResult) {
|
|
9
|
+
const batches: LearningsInput[] = [];
|
|
10
|
+
const learnings: LearningsFn = async (input) => {
|
|
11
|
+
batches.push(input);
|
|
12
|
+
const n = batches.length;
|
|
13
|
+
if (result) return result(input, n);
|
|
14
|
+
if (input.learnings.length === 0) return { kind: "none" };
|
|
15
|
+
const branch = `foreman/learnings-${input.initiativeId}-batch${n}`;
|
|
16
|
+
return {
|
|
17
|
+
kind: "opened",
|
|
18
|
+
pullRequest: { number: 100 + n, url: `https://remote.example/opsee/monorepo/-/merge_requests/${100 + n}`, title: `Foreman: Proposed Learnings for Initiative ${input.initiativeId}`, draft: true, headBranch: branch, baseBranch: "development" },
|
|
19
|
+
count: input.learnings.length,
|
|
20
|
+
file: "docs/learnings.md",
|
|
21
|
+
branch,
|
|
22
|
+
headSha: `sha-${branch}`,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
return { learnings, batches };
|
|
26
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { Attachment, NewPullRequest, PullRequestPatch, RemoteApi, RemotePullRequest } from "./remote-api.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An in-memory code host for the Foreman's tests: keeps the draft pull requests it opened, finds
|
|
6
|
+
* them by branch, and records every call in order so a test can assert that a Hand-off reused a
|
|
7
|
+
* pull request rather than opening a second one, and keeps the comments left on each. Like the
|
|
8
|
+
* seam it implements, it cannot merge.
|
|
9
|
+
*/
|
|
10
|
+
export class FakeRemoteApi implements RemoteApi {
|
|
11
|
+
readonly pullRequests = new Map<number, RemotePullRequest & { body: string }>();
|
|
12
|
+
/** Comments by pull request number, in the order they were added. */
|
|
13
|
+
readonly comments = new Map<number, string[]>();
|
|
14
|
+
/** Files uploaded for a pull request, by number, in order: the local path and the URL given. */
|
|
15
|
+
readonly uploads = new Map<number, Array<{ path: string; url: string }>>();
|
|
16
|
+
readonly calls: string[] = [];
|
|
17
|
+
private nextNumber = 1;
|
|
18
|
+
private nextUpload = 1;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly fullName = "opsee/monorepo") {}
|
|
21
|
+
|
|
22
|
+
async findPullRequest(headBranch: string): Promise<RemotePullRequest | undefined> {
|
|
23
|
+
this.calls.push(`find ${headBranch}`);
|
|
24
|
+
const hit = [...this.pullRequests.values()].find((pr) => pr.headBranch === headBranch);
|
|
25
|
+
return hit ? strip(hit) : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async findPullRequestByTitle(title: string): Promise<RemotePullRequest | undefined> {
|
|
29
|
+
this.calls.push(`find-title ${title}`);
|
|
30
|
+
const hit = [...this.pullRequests.values()].find((pr) => pr.title === title);
|
|
31
|
+
return hit ? strip(hit) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async openDraftPullRequest(input: NewPullRequest): Promise<RemotePullRequest> {
|
|
35
|
+
this.calls.push(`open ${input.headBranch} -> ${input.baseBranch}`);
|
|
36
|
+
const number = this.nextNumber++;
|
|
37
|
+
const pr = { number, url: `https://remote.example/${this.fullName}/-/merge_requests/${number}`, title: input.title, draft: true, headBranch: input.headBranch, baseBranch: input.baseBranch, body: input.body };
|
|
38
|
+
this.pullRequests.set(number, pr);
|
|
39
|
+
return strip(pr);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async updatePullRequest(number: number, patch: PullRequestPatch): Promise<RemotePullRequest> {
|
|
43
|
+
this.calls.push(`update ${number}`);
|
|
44
|
+
const pr = this.pullRequests.get(number);
|
|
45
|
+
if (!pr) throw new Error(`No pull request ${number}`);
|
|
46
|
+
pr.title = patch.title;
|
|
47
|
+
pr.body = patch.body;
|
|
48
|
+
return strip(pr);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async addPullRequestComment(number: number, body: string): Promise<void> {
|
|
52
|
+
this.calls.push(`comment ${number}`);
|
|
53
|
+
if (!this.pullRequests.has(number)) throw new Error(`No pull request ${number}`);
|
|
54
|
+
this.comments.set(number, [...(this.comments.get(number) ?? []), body]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Behaves like GitLab: every file gets a URL and the markdown that embeds it. */
|
|
58
|
+
async uploadAttachment(number: number, filePath: string): Promise<Attachment> {
|
|
59
|
+
this.calls.push(`upload ${number} ${basename(filePath)}`);
|
|
60
|
+
if (!this.pullRequests.has(number)) throw new Error(`No pull request ${number}`);
|
|
61
|
+
const name = basename(filePath);
|
|
62
|
+
const url = `https://remote.example/${this.fullName}/uploads/${this.nextUpload++}/${name}`;
|
|
63
|
+
this.uploads.set(number, [...(this.uploads.get(number) ?? []), { path: filePath, url }]);
|
|
64
|
+
return { url, markdown: `` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function strip({ body: _body, ...pr }: RemotePullRequest & { body: string }): RemotePullRequest {
|
|
69
|
+
return pr;
|
|
70
|
+
}
|