@mikenguyen69/harness 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/build-identity.json +1 -0
- package/dist/cancellation.js +138 -0
- package/dist/cli.js +47 -0
- package/dist/dispatch.js +70 -0
- package/dist/help.js +47 -0
- package/dist/internal/agent/adapter/conformance.js +53 -0
- package/dist/internal/agent/adapter/hook.js +113 -0
- package/dist/internal/agent/adapter/install.js +30 -0
- package/dist/internal/agent/adapter/scope.js +55 -0
- package/dist/internal/agent/adapter/session.js +63 -0
- package/dist/internal/agent/adapter/settings.js +51 -0
- package/dist/internal/agent/cli.js +79 -0
- package/dist/internal/agent/evals/index.js +111 -0
- package/dist/internal/agent/index.js +15 -0
- package/dist/internal/agent/ledger-read.js +27 -0
- package/dist/internal/agent/routing/cli.js +104 -0
- package/dist/internal/agent/routing/index.js +123 -0
- package/dist/internal/agent/telemetry/index.js +97 -0
- package/dist/internal/orchestration/activity/index.js +111 -0
- package/dist/internal/orchestration/analyze/index.js +237 -0
- package/dist/internal/orchestration/cli.js +475 -0
- package/dist/internal/orchestration/config/index.js +66 -0
- package/dist/internal/orchestration/doctor/index.js +202 -0
- package/dist/internal/orchestration/forge/index.js +121 -0
- package/dist/internal/orchestration/git/index.js +181 -0
- package/dist/internal/orchestration/harness/cli.js +378 -0
- package/dist/internal/orchestration/harness/index.js +186 -0
- package/dist/internal/orchestration/harness/shim.js +182 -0
- package/dist/internal/orchestration/index.js +29 -0
- package/dist/internal/orchestration/loop/index.js +1107 -0
- package/dist/internal/orchestration/prompts/index.js +199 -0
- package/dist/internal/orchestration/report/index.js +125 -0
- package/dist/internal/orchestration/routes/index.js +85 -0
- package/dist/internal/orchestration/run/index.js +221 -0
- package/dist/internal/orchestration/runner/index.js +90 -0
- package/dist/internal/orchestration/runners/claude/index.js +246 -0
- package/dist/internal/orchestration/runners/claude/sdk.js +198 -0
- package/dist/internal/orchestration/runners/claude/wiring.js +98 -0
- package/dist/internal/orchestration/runners/codex/exec.js +114 -0
- package/dist/internal/orchestration/runners/codex/index.js +72 -0
- package/dist/internal/orchestration/runners/conformance/index.js +169 -0
- package/dist/internal/orchestration/runners/cursor/exec.js +531 -0
- package/dist/internal/orchestration/runners/cursor/index.js +184 -0
- package/dist/internal/orchestration/runners/stub/index.js +168 -0
- package/dist/internal/orchestration/runners/telemetry.js +36 -0
- package/dist/internal/orchestration/runstate/index.js +213 -0
- package/dist/internal/orchestration/runstate/publish.js +115 -0
- package/dist/internal/orchestration/worktree/index.js +89 -0
- package/dist/internal/system/adapters/index.js +19 -0
- package/dist/internal/system/agentops/index.js +16 -0
- package/dist/internal/system/agentops/scope.js +67 -0
- package/dist/internal/system/assets.js +45 -0
- package/dist/internal/system/board/cli.js +56 -0
- package/dist/internal/system/board/index.js +276 -0
- package/dist/internal/system/board/live.js +277 -0
- package/dist/internal/system/calibration/cli.js +45 -0
- package/dist/internal/system/calibration/index.js +141 -0
- package/dist/internal/system/cli.js +174 -0
- package/dist/internal/system/corpus/replay.js +79 -0
- package/dist/internal/system/digest/cli.js +23 -0
- package/dist/internal/system/digest/index.js +145 -0
- package/dist/internal/system/doors/classify.js +94 -0
- package/dist/internal/system/doors/cli.js +184 -0
- package/dist/internal/system/doors/index.js +249 -0
- package/dist/internal/system/doors/metrics.js +47 -0
- package/dist/internal/system/escapes/cli.js +45 -0
- package/dist/internal/system/escapes/index.js +81 -0
- package/dist/internal/system/explore/cli.js +77 -0
- package/dist/internal/system/explore/index.js +91 -0
- package/dist/internal/system/gates/cli.js +43 -0
- package/dist/internal/system/gates/detectors.js +55 -0
- package/dist/internal/system/gates/pre.js +51 -0
- package/dist/internal/system/hub/cli.js +51 -0
- package/dist/internal/system/hub/index.js +146 -0
- package/dist/internal/system/init/index.js +83 -0
- package/dist/internal/system/ledger/cli.js +54 -0
- package/dist/internal/system/ledger/index.js +176 -0
- package/dist/internal/system/manifest/index.js +207 -0
- package/dist/internal/system/provenance/index.js +13 -0
- package/dist/internal/system/report/index.js +15 -0
- package/dist/internal/system/runner/index.js +74 -0
- package/dist/internal/system/runstatus/cli.js +86 -0
- package/dist/internal/system/runstatus/index.js +168 -0
- package/dist/internal/system/sequencer/briefing.js +75 -0
- package/dist/internal/system/sequencer/cli.js +170 -0
- package/dist/internal/system/sequencer/index.js +156 -0
- package/dist/internal/system/spec/cli.js +110 -0
- package/dist/internal/system/spec/index.js +212 -0
- package/dist/internal/system/spec/materialize.js +111 -0
- package/dist/internal/system/spec/openspec-parse.js +83 -0
- package/dist/internal/system/spec/project.js +208 -0
- package/dist/internal/system/substrate/index.js +14 -0
- package/dist/internal/system/system/index.js +159 -0
- package/dist/internal/system/units/index.js +87 -0
- package/dist/internal/system/verify/cli.js +103 -0
- package/dist/internal/system/verify/declined-reasons.js +61 -0
- package/dist/internal/system/verify/index.js +188 -0
- package/dist/internal/system/verify/signing.js +79 -0
- package/dist/paths.js +50 -0
- package/dist/routes.js +93 -0
- package/dist/version.js +30 -0
- package/dist/win32-process-group.js +360 -0
- package/package.json +41 -0
- package/profiles/python/bindings.toml +59 -0
- package/profiles/terraform/bindings.toml +66 -0
- package/profiles/typescript/bindings.toml +64 -0
- package/profiles/typescript/smoke/README.md +8 -0
- package/schema/baseline.schema.json +26 -0
- package/schema/decision-classes.yaml +45 -0
- package/schema/envelope.schema.json +186 -0
- package/schema/ledger-event.schema.json +235 -0
- package/schema/manifest.schema.json +119 -0
- package/schema/routes.schema.json +55 -0
- package/schema/run-status.schema.json +162 -0
- package/schema/session-event.schema.json +74 -0
- package/schema/spec.schema.json +114 -0
- package/schema/system.schema.json +142 -0
- package/templates/ci/verify.yml +23 -0
- package/templates/target-kit/README.md +78 -0
- package/templates/target-kit/component.toml +20 -0
- package/templates/target-kit/orchestration.json +9 -0
- package/templates/target-kit/profiles/target/bindings.toml +6 -0
- package/templates/target-kit/routes.toml +5 -0
- package/templates/target-kit/specs/first-unit.yaml +11 -0
- package/templates/target-kit/system.toml +7 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Read-only launch preflight for a real orchestration run. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { accessSync, constants, existsSync, readFileSync, statSync, } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { loadOrchestrationConfig } from "../config/index.js";
|
|
6
|
+
import { callProcess, CliHarness, } from "../harness/cli.js";
|
|
7
|
+
import { CliRouteSource } from "../routes/index.js";
|
|
8
|
+
import { loadComponentDescriptors } from "../run/index.js";
|
|
9
|
+
export async function runDoctor(options) {
|
|
10
|
+
const repo = resolve(options.repo);
|
|
11
|
+
const checks = [];
|
|
12
|
+
const readyUnits = [];
|
|
13
|
+
const add = (check) => checks.push(check);
|
|
14
|
+
const configPath = options.configPath ?? join(repo, "orchestration.json");
|
|
15
|
+
const systemToml = join(repo, "system.toml");
|
|
16
|
+
const specsDir = join(repo, "specs");
|
|
17
|
+
const profilesDir = join(repo, "profiles");
|
|
18
|
+
const ledgerDir = join(repo, ".harness", "ledger");
|
|
19
|
+
if (!existsSync(repo) || !statSync(repo).isDirectory()) {
|
|
20
|
+
add({ id: "repo", status: "fail", detail: `repository directory not found: ${repo}`, fix: "pass --repo with an existing checkout" });
|
|
21
|
+
return report();
|
|
22
|
+
}
|
|
23
|
+
let config;
|
|
24
|
+
try {
|
|
25
|
+
config = loadOrchestrationConfig(configPath);
|
|
26
|
+
add({ id: "config", status: "pass", detail: `loaded ${configPath}` });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
add({ id: "config", status: "fail", detail: error.message, fix: "create or repair orchestration.json" });
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const top = git(repo, ["rev-parse", "--show-toplevel"]);
|
|
33
|
+
add({ id: "git-repo", status: "pass", detail: `git worktree: ${top}` });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
add({ id: "git-repo", status: "fail", detail: error.message, fix: "initialize or select a Git checkout" });
|
|
37
|
+
}
|
|
38
|
+
if (config) {
|
|
39
|
+
try {
|
|
40
|
+
git(repo, ["show-ref", "--verify", `refs/heads/${config.baseBranch}`]);
|
|
41
|
+
add({ id: "base-branch", status: "pass", detail: `base branch exists: ${config.baseBranch}` });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
add({ id: "base-branch", status: "fail", detail: `base branch missing: ${config.baseBranch}`, fix: `create/fetch ${config.baseBranch} or correct baseBranch` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const dirty = git(repo, ["status", "--porcelain"]);
|
|
49
|
+
if (dirty && !options.allowDirty) {
|
|
50
|
+
add({ id: "working-tree", status: "fail", detail: "checkout has uncommitted or untracked changes", fix: "commit/stash them, or explicitly rerun doctor with --allow-dirty" });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
add({ id: "working-tree", status: dirty ? "warn" : "pass", detail: dirty ? "dirty checkout explicitly allowed" : "checkout is clean" });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// git-repo already carries the blocking diagnostic
|
|
58
|
+
}
|
|
59
|
+
let components = new Map();
|
|
60
|
+
try {
|
|
61
|
+
components = loadComponentDescriptors(systemToml);
|
|
62
|
+
const missing = [...components.values()].flatMap((component) => {
|
|
63
|
+
const paths = [join(component.root, "component.toml"), join(profilesDir, component.profile, "bindings.toml")];
|
|
64
|
+
return paths.filter((path) => !existsSync(path));
|
|
65
|
+
});
|
|
66
|
+
if (missing.length) {
|
|
67
|
+
add({ id: "components", status: "fail", detail: `missing component/profile files: ${missing.join(", ")}`, fix: "repair system.toml roots and install every declared profile" });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
add({ id: "components", status: "pass", detail: `${components.size} component(s) and profiles resolved` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
add({ id: "components", status: "fail", detail: error.message, fix: "create or repair system.toml and component.toml" });
|
|
75
|
+
}
|
|
76
|
+
const specCheck = await callProcess({
|
|
77
|
+
command: options.harness.command,
|
|
78
|
+
args: [...options.harness.args, "spec", "check", "--specs", specsDir, "--system", systemToml],
|
|
79
|
+
cwd: repo,
|
|
80
|
+
});
|
|
81
|
+
add(specCheck.code === 0
|
|
82
|
+
? { id: "specs", status: "pass", detail: "spec schema and dependency DAG pass" }
|
|
83
|
+
: { id: "specs", status: "fail", detail: (specCheck.stderr || specCheck.stdout).trim(), fix: "run the printed harness spec check and repair every finding" });
|
|
84
|
+
const chain = await callProcess({
|
|
85
|
+
command: options.harness.command,
|
|
86
|
+
args: [...options.harness.args, "ledger", "verify-chain", "--dir", ledgerDir],
|
|
87
|
+
cwd: repo,
|
|
88
|
+
});
|
|
89
|
+
add(chain.code === 0
|
|
90
|
+
? { id: "ledger", status: "pass", detail: chain.stdout.trim() }
|
|
91
|
+
: { id: "ledger", status: "fail", detail: (chain.stderr || chain.stdout).trim(), fix: "restore the ledger from trusted evidence; do not hand-edit JSONL" });
|
|
92
|
+
const harness = new CliHarness({
|
|
93
|
+
bin: options.harness.command,
|
|
94
|
+
binArgs: options.harness.args,
|
|
95
|
+
cwd: repo,
|
|
96
|
+
ledgerDir,
|
|
97
|
+
specsDir,
|
|
98
|
+
profilesDir,
|
|
99
|
+
systemToml,
|
|
100
|
+
components,
|
|
101
|
+
systemId: basename(repo),
|
|
102
|
+
actorId: "doctor-read-only",
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const ready = await harness.next();
|
|
106
|
+
const routes = new CliRouteSource({
|
|
107
|
+
bin: options.agentHarness.command,
|
|
108
|
+
binArgs: options.agentHarness.args,
|
|
109
|
+
cwd: repo,
|
|
110
|
+
routesPath: options.routesPath,
|
|
111
|
+
ledgerDir,
|
|
112
|
+
profileForComponent: (component) => component ? components.get(component)?.profile : undefined,
|
|
113
|
+
});
|
|
114
|
+
for (const unit of ready) {
|
|
115
|
+
const policy = await routes.resolve(unit.id, unit.component);
|
|
116
|
+
readyUnits.push({
|
|
117
|
+
id: unit.id,
|
|
118
|
+
...(unit.component ? { component: unit.component } : {}),
|
|
119
|
+
runner: policy.runner,
|
|
120
|
+
model: policy.model,
|
|
121
|
+
});
|
|
122
|
+
if (config && !config.runnersEnabled.includes(policy.runner)) {
|
|
123
|
+
add({ id: `route:${unit.id}`, status: "fail", detail: `${unit.id} resolves to disabled runner ${policy.runner}`, fix: `enable ${policy.runner} or change ${options.routesPath}` });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
add({ id: `route:${unit.id}`, status: "pass", detail: `${unit.id} → ${policy.runner}/${policy.model}` });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (ready.length === 0)
|
|
130
|
+
add({ id: "routes", status: "warn", detail: "no currently ready units to route" });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
add({ id: "routes", status: "fail", detail: error.message, fix: `create/fix ${options.routesPath} and ensure every component profile resolves` });
|
|
134
|
+
}
|
|
135
|
+
if (config) {
|
|
136
|
+
const bounded = config.maxRunCost !== undefined && config.maxRunDuration !== undefined;
|
|
137
|
+
add(bounded
|
|
138
|
+
? { id: "budgets", status: "pass", detail: `cost <= ${config.maxRunCost}; duration <= ${config.maxRunDuration}ms` }
|
|
139
|
+
: { id: "budgets", status: "warn", detail: "one or more run budgets are uncapped", fix: "set maxRunCost and maxRunDuration before a paid run" });
|
|
140
|
+
}
|
|
141
|
+
const hookText = safeRead(join(repo, ".claude", "settings.json"));
|
|
142
|
+
add(hookText.includes("hook pre-tool-use")
|
|
143
|
+
? { id: "scope-hook", status: "pass", detail: "Claude PreToolUse scope hook is installed" }
|
|
144
|
+
: { id: "scope-hook", status: "warn", detail: "Claude scope hook is not installed yet; ClaudeRunner installs it before dispatch" });
|
|
145
|
+
add(existsSync(join(repo, ".harness", "operator-key.pem"))
|
|
146
|
+
? { id: "operator-key", status: "pass", detail: "operator signing key exists" }
|
|
147
|
+
: { id: "operator-key", status: "warn", detail: "operator key is absent; a one-way door can halt but cannot yet be approved", fix: "a human runs harness door init-key before approval" });
|
|
148
|
+
if (options.spoolDir) {
|
|
149
|
+
const writable = writableAncestor(options.spoolDir);
|
|
150
|
+
add(writable.ok
|
|
151
|
+
? { id: "spool", status: "pass", detail: `evidence spool is writable via ${writable.path}` }
|
|
152
|
+
: { id: "spool", status: "fail", detail: `evidence spool is not writable: ${options.spoolDir}`, fix: "choose a writable --spool directory" });
|
|
153
|
+
}
|
|
154
|
+
add({
|
|
155
|
+
id: "executables",
|
|
156
|
+
status: "pass",
|
|
157
|
+
detail: `harness=${renderCommand(options.harness)}; agent-harness=${renderCommand(options.agentHarness)}`,
|
|
158
|
+
});
|
|
159
|
+
return report();
|
|
160
|
+
function report() {
|
|
161
|
+
return {
|
|
162
|
+
status: checks.some((check) => check.status === "fail") ? "blocked" : "ready",
|
|
163
|
+
repo,
|
|
164
|
+
checks,
|
|
165
|
+
commands: { harness: options.harness, agentHarness: options.agentHarness },
|
|
166
|
+
readyUnits,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function git(cwd, args) {
|
|
171
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
172
|
+
}
|
|
173
|
+
function safeRead(path) {
|
|
174
|
+
try {
|
|
175
|
+
return readFileSync(path, "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function writableAncestor(path) {
|
|
182
|
+
let current = resolve(path);
|
|
183
|
+
while (!existsSync(current)) {
|
|
184
|
+
const parent = dirname(current);
|
|
185
|
+
if (parent === current)
|
|
186
|
+
return { ok: false, path: current };
|
|
187
|
+
current = parent;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
if (!statSync(current).isDirectory())
|
|
191
|
+
return { ok: false, path: current };
|
|
192
|
+
accessSync(current, constants.W_OK);
|
|
193
|
+
return { ok: true, path: current };
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return { ok: false, path: current };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function renderCommand(spec) {
|
|
200
|
+
return [spec.command, ...spec.args].map((part) => part.includes(" ") ? `"${part}"` : part).join(" ");
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge — how a verified, rebased unit branch *lands* on the base branch. Two
|
|
3
|
+
* implementations (v0-plan M4):
|
|
4
|
+
* - LocalForge — fast-forward `baseBranch` to the branch tip (no PR).
|
|
5
|
+
* - GithubForge — push the branch, open/reuse its PR, merge on green.
|
|
6
|
+
*
|
|
7
|
+
* The loop calls `forge.land()` only after the system harness authorizes the
|
|
8
|
+
* merge facts and signed doors. The actual `unit.merged` fact is recorded only
|
|
9
|
+
* after this returns the landed object id.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
/** No PR — fast-forward the base ref to the (already rebased) branch tip. */
|
|
15
|
+
export class LocalForge {
|
|
16
|
+
git;
|
|
17
|
+
kind = "local";
|
|
18
|
+
constructor(git) {
|
|
19
|
+
this.git = git;
|
|
20
|
+
}
|
|
21
|
+
async land(p) {
|
|
22
|
+
const ff = await this.git.fastForward(p.base, p.tipSha);
|
|
23
|
+
return ff.ok
|
|
24
|
+
? { ok: true, landedSha: p.tipSha }
|
|
25
|
+
: { ok: false, reason: ff.reason ?? "fast-forward failed" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Push the branch, open or reuse its PR, merge it (`--merge --auto` off — we gate). */
|
|
29
|
+
export class GithubForge {
|
|
30
|
+
opts;
|
|
31
|
+
kind = "github";
|
|
32
|
+
bin;
|
|
33
|
+
gitBin;
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.opts = opts;
|
|
36
|
+
this.bin = opts.bin ?? "gh";
|
|
37
|
+
this.gitBin = opts.gitBin ?? "git";
|
|
38
|
+
}
|
|
39
|
+
async gitCmd(args) {
|
|
40
|
+
if (this.opts.execGit)
|
|
41
|
+
return this.opts.execGit(args);
|
|
42
|
+
try {
|
|
43
|
+
const { stdout, stderr } = await run(this.gitBin, args, { cwd: this.opts.cwd });
|
|
44
|
+
return { code: 0, stdout, stderr };
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
const err = e;
|
|
48
|
+
return {
|
|
49
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
50
|
+
stdout: err.stdout ?? "",
|
|
51
|
+
stderr: err.stderr ?? "",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async gh(args) {
|
|
56
|
+
if (this.opts.exec)
|
|
57
|
+
return this.opts.exec(args);
|
|
58
|
+
try {
|
|
59
|
+
const { stdout, stderr } = await run(this.bin, args, { cwd: this.opts.cwd });
|
|
60
|
+
return { code: 0, stdout, stderr };
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
const err = e;
|
|
64
|
+
return {
|
|
65
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
66
|
+
stdout: err.stdout ?? "",
|
|
67
|
+
stderr: err.stderr ?? "",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async land(p) {
|
|
72
|
+
// `gh pr create --head <branch>` needs the branch on the remote first; the
|
|
73
|
+
// orchestrator rebases the unit branch before landing, so the remote ref may
|
|
74
|
+
// be behind — force-with-lease keeps the push safe for this owned branch.
|
|
75
|
+
const pushed = await this.gitCmd([
|
|
76
|
+
"push",
|
|
77
|
+
"--force-with-lease",
|
|
78
|
+
"origin",
|
|
79
|
+
`${p.branch}:refs/heads/${p.branch}`,
|
|
80
|
+
]);
|
|
81
|
+
if (pushed.code !== 0) {
|
|
82
|
+
return { ok: false, reason: `git push: ${(pushed.stderr || pushed.stdout).trim()}` };
|
|
83
|
+
}
|
|
84
|
+
const push = await this.gh([
|
|
85
|
+
"pr",
|
|
86
|
+
"create",
|
|
87
|
+
"--head",
|
|
88
|
+
p.branch,
|
|
89
|
+
"--base",
|
|
90
|
+
p.base,
|
|
91
|
+
"--title",
|
|
92
|
+
`${p.unitId}`,
|
|
93
|
+
"--body",
|
|
94
|
+
`Landed by orchestrate. Unit: ${p.unitId}`,
|
|
95
|
+
"--fill",
|
|
96
|
+
]);
|
|
97
|
+
// `pr create` fails if a PR already exists — that is fine, proceed to merge.
|
|
98
|
+
const created = push.code === 0;
|
|
99
|
+
const existsAlready = /already exists/i.test(push.stderr);
|
|
100
|
+
if (!created && !existsAlready) {
|
|
101
|
+
return { ok: false, reason: `gh pr create: ${(push.stderr || push.stdout).trim()}` };
|
|
102
|
+
}
|
|
103
|
+
const merge = await this.gh([
|
|
104
|
+
"pr",
|
|
105
|
+
"merge",
|
|
106
|
+
p.branch,
|
|
107
|
+
"--merge",
|
|
108
|
+
"--delete-branch=false",
|
|
109
|
+
]);
|
|
110
|
+
if (merge.code !== 0) {
|
|
111
|
+
return { ok: false, reason: `gh pr merge: ${(merge.stderr || merge.stdout).trim()}` };
|
|
112
|
+
}
|
|
113
|
+
// the PR merge commit is the landed sha; ask gh for it.
|
|
114
|
+
const sha = await this.gh(["pr", "view", p.branch, "--json", "mergeCommit", "-q", ".mergeCommit.oid"]);
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
landedSha: sha.code === 0 && sha.stdout.trim() ? sha.stdout.trim() : p.tipSha,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git — the real `Git`, shelling `git` in the driven project's checkout. M3
|
|
3
|
+
* needs an actual rebase; M4 layers `gh` / PR handling on top of the same
|
|
4
|
+
* interface. Nothing here reaches for a git library — plain subprocess calls,
|
|
5
|
+
* so the behaviour matches what an operator would see on the command line.
|
|
6
|
+
*
|
|
7
|
+
* All operations run with `cwd` at the driven checkout. `rebase` never throws on
|
|
8
|
+
* a conflict: it aborts the rebase and returns `{ ok: false, conflict }` so the
|
|
9
|
+
* lane can treat it as a retry.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
15
|
+
export class ShellGit {
|
|
16
|
+
opts;
|
|
17
|
+
bin;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
this.bin = opts.bin ?? "git";
|
|
21
|
+
}
|
|
22
|
+
async git(args, cwd = this.opts.cwd, extra) {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout, stderr } = await run(this.bin, args, {
|
|
25
|
+
cwd,
|
|
26
|
+
timeout: extra?.timeout,
|
|
27
|
+
env: extra?.env ? { ...process.env, ...extra.env } : undefined,
|
|
28
|
+
});
|
|
29
|
+
return { code: 0, stdout, stderr };
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
const err = e;
|
|
33
|
+
return {
|
|
34
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
35
|
+
stdout: err.stdout ?? "",
|
|
36
|
+
stderr: err.stderr ?? "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async headSha(ref) {
|
|
41
|
+
const { stdout } = await this.git(["rev-parse", ref]);
|
|
42
|
+
return stdout.trim();
|
|
43
|
+
}
|
|
44
|
+
async isAncestor(sha, ref) {
|
|
45
|
+
const { code } = await this.git(["merge-base", "--is-ancestor", sha, ref]);
|
|
46
|
+
return code === 0;
|
|
47
|
+
}
|
|
48
|
+
async fetchRemoteTracking(branch) {
|
|
49
|
+
try {
|
|
50
|
+
const remotes = await this.git(["remote"]);
|
|
51
|
+
if (remotes.code !== 0 || !remotes.stdout.trim())
|
|
52
|
+
return { ok: false };
|
|
53
|
+
const remote = await this.selectRemote(branch, remotes.stdout);
|
|
54
|
+
if (!remote)
|
|
55
|
+
return { ok: false };
|
|
56
|
+
const localBranch = stripHeads(branch);
|
|
57
|
+
const src = `refs/heads/${localBranch}`;
|
|
58
|
+
const dest = `refs/remotes/${remote}/${localBranch}`;
|
|
59
|
+
// `+` matches git's default fetch refspec (force-update tracking only).
|
|
60
|
+
// Destination is always a remote-tracking ref — never a local branch.
|
|
61
|
+
const fetched = await this.git(["fetch", "--no-tags", "--", remote, `+${src}:${dest}`], this.opts.cwd, { timeout: FETCH_TIMEOUT_MS, env: { GIT_TERMINAL_PROMPT: "0" } });
|
|
62
|
+
if (fetched.code !== 0)
|
|
63
|
+
return { ok: false };
|
|
64
|
+
return { ok: true, trackingRef: `${remote}/${localBranch}` };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ok: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async selectRemote(branch, remoteList) {
|
|
71
|
+
const names = remoteList
|
|
72
|
+
.split(/\r?\n/)
|
|
73
|
+
.map((s) => s.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
if (names.length === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
const configured = await this.git(["config", "--get", `branch.${stripHeads(branch)}.remote`]);
|
|
78
|
+
const preferred = configured.stdout.trim();
|
|
79
|
+
if (configured.code === 0 && preferred && names.includes(preferred))
|
|
80
|
+
return preferred;
|
|
81
|
+
if (names.includes("origin"))
|
|
82
|
+
return "origin";
|
|
83
|
+
return names[0];
|
|
84
|
+
}
|
|
85
|
+
async rebase(branch, onto, workspace) {
|
|
86
|
+
const cwd = workspace?.path ?? this.opts.cwd;
|
|
87
|
+
if (!workspace) {
|
|
88
|
+
const co = await this.git(["checkout", branch], cwd);
|
|
89
|
+
if (co.code !== 0) {
|
|
90
|
+
return { ok: false, conflict: `checkout ${branch} failed: ${co.stderr.trim()}` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const current = await this.git(["branch", "--show-current"], cwd);
|
|
95
|
+
if (current.stdout.trim() !== branch) {
|
|
96
|
+
return { ok: false, conflict: `worktree ${cwd} is on ${current.stdout.trim()}, expected ${branch}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const rb = await this.git(["rebase", onto], cwd);
|
|
100
|
+
if (rb.code === 0) {
|
|
101
|
+
return { ok: true, tipSha: (await this.git(["rev-parse", "HEAD"], cwd)).stdout.trim() };
|
|
102
|
+
}
|
|
103
|
+
// capture the conflict, then leave the tree clean.
|
|
104
|
+
const status = await this.git(["diff", "--name-only", "--diff-filter=U"], cwd);
|
|
105
|
+
await this.git(["rebase", "--abort"], cwd);
|
|
106
|
+
const files = status.stdout.trim();
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
conflict: `git rebase ${branch} onto ${onto} failed:\n` +
|
|
110
|
+
`${(rb.stderr || rb.stdout).trim()}` +
|
|
111
|
+
(files ? `\nconflicted paths:\n${files}` : ""),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async fastForward(ref, sha) {
|
|
115
|
+
const branch = stripHeads(ref);
|
|
116
|
+
const current = await this.git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
117
|
+
if (current.code !== 0 || current.stdout.trim() !== branch) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: `control checkout is on ${current.stdout.trim() || "detached HEAD"}, expected ${branch}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// A checkout-aware fast-forward updates the ref, index, and visible files
|
|
124
|
+
// together. `update-ref` alone left checked-out main pointing at a tree the
|
|
125
|
+
// worktree did not display.
|
|
126
|
+
const old = await this.headSha(ref);
|
|
127
|
+
const ancestor = await this.git(["merge-base", "--is-ancestor", old, sha]);
|
|
128
|
+
if (ancestor.code !== 0) {
|
|
129
|
+
return { ok: false, reason: `${ref} is not an ancestor of ${sha} — not a fast-forward` };
|
|
130
|
+
}
|
|
131
|
+
const merged = await this.git(["merge", "--ff-only", sha]);
|
|
132
|
+
if (merged.code !== 0) {
|
|
133
|
+
return { ok: false, reason: (merged.stderr || merged.stdout).trim() };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
async changedFiles(branch, base, workspace) {
|
|
138
|
+
// files the branch changed relative to its merge-base with the base branch.
|
|
139
|
+
const { stdout } = await this.git(["diff", "--name-only", `${base}...${branch}`], workspace?.path);
|
|
140
|
+
return stdout
|
|
141
|
+
.split("\n")
|
|
142
|
+
.map((l) => l.trim())
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
async diffText(branch, base, workspace) {
|
|
146
|
+
// patch vs. the merge-base — what the branch actually proposes.
|
|
147
|
+
return (await this.git(["diff", `${base}...${branch}`], workspace?.path)).stdout;
|
|
148
|
+
}
|
|
149
|
+
async workingTreeFiles(workspace) {
|
|
150
|
+
const { stdout } = await this.git(["status", "--porcelain"], workspace.path);
|
|
151
|
+
return parsePorcelain(stdout);
|
|
152
|
+
}
|
|
153
|
+
async commitWorkspace(workspace, message) {
|
|
154
|
+
const cwd = workspace.path;
|
|
155
|
+
const add = await this.git(["add", "-A"], cwd);
|
|
156
|
+
if (add.code !== 0) {
|
|
157
|
+
throw new Error(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`);
|
|
158
|
+
}
|
|
159
|
+
const commit = await this.git(["commit", "-m", message], cwd);
|
|
160
|
+
if (commit.code !== 0) {
|
|
161
|
+
throw new Error(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`);
|
|
162
|
+
}
|
|
163
|
+
return (await this.git(["rev-parse", "HEAD"], cwd)).stdout.trim();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function parsePorcelain(stdout) {
|
|
167
|
+
const files = [];
|
|
168
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
169
|
+
if (!line.trim())
|
|
170
|
+
continue;
|
|
171
|
+
const raw = line.slice(3).trim();
|
|
172
|
+
const path = raw.includes(" -> ") ? raw.split(" -> ").pop().trim() : raw;
|
|
173
|
+
if (path)
|
|
174
|
+
files.push(path.replace(/\\/g, "/"));
|
|
175
|
+
}
|
|
176
|
+
return files;
|
|
177
|
+
}
|
|
178
|
+
function stripHeads(ref) {
|
|
179
|
+
return ref.replace(/^refs\/heads\//, "");
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=index.js.map
|