omp-conductor 0.2.0
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 +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
package/src/routing.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eligibility, repo routing and branch naming.
|
|
3
|
+
*
|
|
4
|
+
* Pure by design: every decision here is a function of the issue plus the
|
|
5
|
+
* project config, so the dispatcher's riskiest choice — "which checkout does
|
|
6
|
+
* this issue belong in" — is testable without a tracker, a clone or a network.
|
|
7
|
+
* The module refuses to guess. An issue that names two repos, or none, is
|
|
8
|
+
* handed back as unroutable rather than dispatched somewhere plausible,
|
|
9
|
+
* because a multi-repo request silently taken whole by one worker is exactly
|
|
10
|
+
* the failure this guard exists to prevent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ProjectConfig, ReadyIssue, RepoTarget } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** An issue paired with the single checkout its labels unambiguously name. */
|
|
16
|
+
export type Routed = { issue: ReadyIssue; repo: RepoTarget };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why an otherwise-eligible issue could not be dispatched. All three are
|
|
20
|
+
* human-fixable label problems, which is why they escalate rather than fail.
|
|
21
|
+
*/
|
|
22
|
+
export type UnroutableReason =
|
|
23
|
+
| "no-repo-label"
|
|
24
|
+
| "multiple-repo-labels"
|
|
25
|
+
| "unknown-repo";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `labels` carries the prefixed labels actually seen, so the escalation can
|
|
29
|
+
* quote them back ("saw repo:api, repo:worker") instead of telling a human to
|
|
30
|
+
* go and look.
|
|
31
|
+
*/
|
|
32
|
+
export type Unroutable = {
|
|
33
|
+
issue: ReadyIssue;
|
|
34
|
+
reason: UnroutableReason;
|
|
35
|
+
labels: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Git refs stay short enough to read in a PR list without wrapping. */
|
|
39
|
+
const MAX_BRANCH_LEN = 60;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True only when a human has queued the issue and no run already owns it.
|
|
43
|
+
*
|
|
44
|
+
* The state labels are the interlock against double-dispatch across daemon
|
|
45
|
+
* restarts: the tracker, not the local store, is the source of truth for
|
|
46
|
+
* "someone is already on this".
|
|
47
|
+
*/
|
|
48
|
+
export function isEligible(issue: ReadyIssue, p: ProjectConfig): boolean {
|
|
49
|
+
// ponytail: label comparison is exact and case-sensitive. Ceiling — a human
|
|
50
|
+
// typing `Agent-Ready` gets silently ignored. Upgrade path: case-fold both
|
|
51
|
+
// sides here, which is the only place labels are matched.
|
|
52
|
+
const labels = new Set(issue.labels);
|
|
53
|
+
if (!labels.has(p.queueLabel)) return false;
|
|
54
|
+
const { inProgress, blocked, failed } = p.stateLabels;
|
|
55
|
+
return !labels.has(inProgress) && !labels.has(blocked) && !labels.has(failed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Partition eligible issues into dispatchable and needs-a-human.
|
|
60
|
+
*
|
|
61
|
+
* Ineligible issues appear in neither bucket — they are not this loop's
|
|
62
|
+
* business, and reporting them as unroutable would escalate every in-flight
|
|
63
|
+
* run on every poll.
|
|
64
|
+
*/
|
|
65
|
+
export function route(
|
|
66
|
+
issues: ReadyIssue[],
|
|
67
|
+
p: ProjectConfig,
|
|
68
|
+
): { routed: Routed[]; unroutable: Unroutable[] } {
|
|
69
|
+
const routed: Routed[] = [];
|
|
70
|
+
const unroutable: Unroutable[] = [];
|
|
71
|
+
const { labelPrefix, repos } = p.routing;
|
|
72
|
+
|
|
73
|
+
for (const issue of issues) {
|
|
74
|
+
if (!isEligible(issue, p)) continue;
|
|
75
|
+
|
|
76
|
+
// Deduplicated: a repeated label is one repo, not an ambiguity.
|
|
77
|
+
const matched = [
|
|
78
|
+
...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix))),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
if (matched.length === 0) {
|
|
82
|
+
unroutable.push({ issue, reason: "no-repo-label", labels: matched });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (matched.length > 1) {
|
|
86
|
+
unroutable.push({
|
|
87
|
+
issue,
|
|
88
|
+
reason: "multiple-repo-labels",
|
|
89
|
+
labels: matched,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const key = matched[0]!.slice(labelPrefix.length);
|
|
95
|
+
// hasOwn, not truthiness: a `repo:constructor` label would otherwise
|
|
96
|
+
// resolve off Object.prototype and route work into a bogus target.
|
|
97
|
+
if (!Object.hasOwn(repos, key)) {
|
|
98
|
+
unroutable.push({ issue, reason: "unknown-repo", labels: matched });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
routed.push({ issue, repo: repos[key]! });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { routed, unroutable };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `bug` → `fix`, everything else → `feat`.
|
|
109
|
+
*
|
|
110
|
+
* The label may be namespaced (`type:bug`, `kind/bug`), so only its last
|
|
111
|
+
* segment is compared — the same label means the same thing whichever
|
|
112
|
+
* convention a repo uses.
|
|
113
|
+
*/
|
|
114
|
+
function inferType(labels: string[]): "fix" | "feat" {
|
|
115
|
+
for (const label of labels) {
|
|
116
|
+
const lower = label.toLowerCase().trim();
|
|
117
|
+
const cut = Math.max(lower.lastIndexOf(":"), lower.lastIndexOf("/"));
|
|
118
|
+
if (lower.slice(cut + 1).trim() === "bug") return "fix";
|
|
119
|
+
}
|
|
120
|
+
return "feat";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Title → `[a-z0-9-]`, runs of `-` collapsed, ends trimmed.
|
|
125
|
+
*
|
|
126
|
+
* Every character git dislikes in a ref (`.`, `:`, `~`, `^`, `?`, `*`, `[`,
|
|
127
|
+
* `\`, whitespace) is outside the allow-list, so a slug cannot produce an
|
|
128
|
+
* invalid ref — no `..`, no leading or trailing dot, no space.
|
|
129
|
+
*/
|
|
130
|
+
function slugify(title: string): string {
|
|
131
|
+
return (
|
|
132
|
+
title
|
|
133
|
+
// Fold accents so "Café crash" keeps its words instead of dissolving.
|
|
134
|
+
.normalize("NFKD")
|
|
135
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
138
|
+
.replace(/^-+|-+$/g, "")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Deterministic `<type>/<slug>` branch name, capped whole at 60 characters and
|
|
144
|
+
* never ending in `-`.
|
|
145
|
+
*
|
|
146
|
+
* Determinism matters beyond tidiness: a resumed or retried run recomputes the
|
|
147
|
+
* same branch and finds its own work instead of forking a second one.
|
|
148
|
+
*/
|
|
149
|
+
export function branchName(issue: ReadyIssue): string {
|
|
150
|
+
const type = inferType(issue.labels);
|
|
151
|
+
// ponytail: a CJK or emoji-only title slugifies to nothing and lands on
|
|
152
|
+
// `issue-<n>`. Ceiling — such branches read as opaque. Upgrade path:
|
|
153
|
+
// transliterate here, or use the tracker's own slug when it exposes one.
|
|
154
|
+
const fallback = `issue-${issue.number}`;
|
|
155
|
+
const budget = Math.max(MAX_BRANCH_LEN - type.length - 1, 1);
|
|
156
|
+
const slug = (slugify(issue.title) || fallback)
|
|
157
|
+
.slice(0, budget)
|
|
158
|
+
.replace(/-+$/, "");
|
|
159
|
+
return `${type}/${slug || fallback.slice(0, budget)}`;
|
|
160
|
+
}
|