omp-conductor 0.2.1 → 0.3.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/README.md +238 -44
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +33 -22
- package/src/briefs/orchestrator.md +89 -25
- package/src/briefs/worker.md +9 -2
- package/src/cli.ts +291 -13
- package/src/config.ts +127 -16
- package/src/daemon.ts +520 -54
- package/src/lifecycle.ts +6 -4
- package/src/orchestrator-tick.ts +209 -45
- package/src/plugin.ts +60 -0
- package/src/setup.ts +98 -4
- package/src/store.ts +41 -4
- package/src/tracker/github.ts +94 -2
- package/src/types.ts +62 -4
- package/src/unblock.ts +113 -0
- package/src/worker.ts +14 -0
- package/src/worktree.ts +119 -0
package/src/config.ts
CHANGED
|
@@ -16,9 +16,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import {
|
|
19
|
+
AUTHORITY_HOLDERS,
|
|
19
20
|
CONFIG_VERSION,
|
|
21
|
+
DEFAULT_AUTHORITY,
|
|
20
22
|
DEFAULT_CAPS,
|
|
21
23
|
DEFAULT_REPORT_SCOPE,
|
|
24
|
+
ORCHESTRATOR_MODES,
|
|
22
25
|
READABLE_CONFIG_VERSIONS,
|
|
23
26
|
REPORT_SCOPES,
|
|
24
27
|
type Caps,
|
|
@@ -43,8 +46,10 @@ type Raw = { readonly [key: string]: unknown };
|
|
|
43
46
|
/** Derived from the data so a new `Caps` field cannot be silently ignored. */
|
|
44
47
|
const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
45
48
|
|
|
46
|
-
/** Quoted for error messages, from the same data the
|
|
47
|
-
const REPORT_SCOPE_LIST = REPORT_SCOPES
|
|
49
|
+
/** Quoted for error messages, from the same data the guards below read. */
|
|
50
|
+
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
51
|
+
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
52
|
+
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
48
53
|
|
|
49
54
|
/** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
|
|
50
55
|
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
@@ -267,13 +272,8 @@ function normalizeProject(
|
|
|
267
272
|
|
|
268
273
|
const stateLabels = raw["stateLabels"] as Raw | undefined;
|
|
269
274
|
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
const escalation: ProjectConfig["escalation"] = {
|
|
273
|
-
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
274
|
-
fallbackToIssueComment: escalationIn?.["fallbackToIssueComment"] !== false,
|
|
275
|
-
};
|
|
276
|
-
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
275
|
+
const escalation = normalizeEscalation(raw["escalation"], label, problems);
|
|
276
|
+
const authority = normalizeAuthority(raw["authority"], label, problems);
|
|
277
277
|
|
|
278
278
|
const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
|
|
279
279
|
const reporting = normalizeReporting(raw["reporting"], label, problems);
|
|
@@ -298,6 +298,7 @@ function normalizeProject(
|
|
|
298
298
|
caps,
|
|
299
299
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
300
300
|
escalation,
|
|
301
|
+
authority,
|
|
301
302
|
reporting,
|
|
302
303
|
workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
|
|
303
304
|
mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
|
|
@@ -326,14 +327,92 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
326
327
|
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
327
328
|
}
|
|
328
329
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
330
|
+
return {
|
|
331
|
+
scope: pickLiteral(
|
|
332
|
+
raw["scope"],
|
|
333
|
+
REPORT_SCOPES,
|
|
334
|
+
DEFAULT_REPORT_SCOPE,
|
|
335
|
+
`${label}: reporting.scope`,
|
|
336
|
+
REPORT_SCOPE_LIST,
|
|
337
|
+
problems,
|
|
338
|
+
),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Who triages escalations, and how they are delivered when nobody answers.
|
|
344
|
+
*
|
|
345
|
+
* `orchestrator` is validated rather than folded to the default for the reason
|
|
346
|
+
* `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
|
|
347
|
+
* `"embedded"` would start a second brain beside the operator's own session,
|
|
348
|
+
* and both of them would triage the same issue from different transcripts.
|
|
349
|
+
*/
|
|
350
|
+
function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
|
|
351
|
+
let raw: Raw = {};
|
|
352
|
+
if (parsed !== undefined) {
|
|
353
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
|
|
354
|
+
else problems.push(`${label}: escalation must be an object`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const escalation: ProjectConfig["escalation"] = {
|
|
358
|
+
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
359
|
+
fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
|
|
360
|
+
orchestrator: pickLiteral(
|
|
361
|
+
raw["orchestrator"],
|
|
362
|
+
ORCHESTRATOR_MODES,
|
|
363
|
+
"embedded",
|
|
364
|
+
`${label}: escalation.orchestrator`,
|
|
365
|
+
ORCHESTRATOR_MODE_LIST,
|
|
366
|
+
problems,
|
|
367
|
+
),
|
|
368
|
+
};
|
|
369
|
+
const chatId = raw["telegramChatId"];
|
|
370
|
+
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
371
|
+
return escalation;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Who lands PRs and who cuts releases. Both default to the human: this is the
|
|
376
|
+
* one config value that decides whether an unattended session may write to a
|
|
377
|
+
* main branch, so it is granted explicitly or not at all.
|
|
378
|
+
*
|
|
379
|
+
* Unknown keys are rejected outright, as in `reporting` and for the same
|
|
380
|
+
* reason: the object has exactly two members, so an unrecognised one is a typo
|
|
381
|
+
* every time — and a `authority: { merges: "orchestrator" }` that loaded
|
|
382
|
+
* cleanly would read as delegated while the orchestrator was still told to keep
|
|
383
|
+
* its hands off.
|
|
384
|
+
*/
|
|
385
|
+
function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
|
|
386
|
+
if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
|
|
387
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
388
|
+
problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
|
|
389
|
+
return { ...DEFAULT_AUTHORITY };
|
|
335
390
|
}
|
|
336
|
-
|
|
391
|
+
const raw = parsed as Raw;
|
|
392
|
+
|
|
393
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
394
|
+
if (unknownKeys.length > 0) {
|
|
395
|
+
problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
merge: pickLiteral(
|
|
400
|
+
raw["merge"],
|
|
401
|
+
AUTHORITY_HOLDERS,
|
|
402
|
+
DEFAULT_AUTHORITY.merge,
|
|
403
|
+
`${label}: authority.merge`,
|
|
404
|
+
AUTHORITY_HOLDER_LIST,
|
|
405
|
+
problems,
|
|
406
|
+
),
|
|
407
|
+
release: pickLiteral(
|
|
408
|
+
raw["release"],
|
|
409
|
+
AUTHORITY_HOLDERS,
|
|
410
|
+
DEFAULT_AUTHORITY.release,
|
|
411
|
+
`${label}: authority.release`,
|
|
412
|
+
AUTHORITY_HOLDER_LIST,
|
|
413
|
+
problems,
|
|
414
|
+
),
|
|
415
|
+
};
|
|
337
416
|
}
|
|
338
417
|
|
|
339
418
|
function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
@@ -439,6 +518,38 @@ function pickString(v: unknown, fallback: string): string {
|
|
|
439
518
|
return nonEmptyString(v) ? v : fallback;
|
|
440
519
|
}
|
|
441
520
|
|
|
521
|
+
/** Quoted alternatives for an error message, from the same data the guard reads. */
|
|
522
|
+
function quoteList(values: readonly string[]): string {
|
|
523
|
+
return values.map((v) => `"${v}"`).join(" or ");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* One rule for "a declared literal out of a closed set, else the documented
|
|
528
|
+
* default", used by every such field here.
|
|
529
|
+
*
|
|
530
|
+
* Absent takes the default silently; a value outside the set is always
|
|
531
|
+
* reported and never folded. Each of these sets decides something the operator
|
|
532
|
+
* would otherwise believe they had configured — who merges, who triages, how
|
|
533
|
+
* loud the fleet is — and a typo that resolves to the default reads exactly
|
|
534
|
+
* like a deliberate choice in the file afterwards.
|
|
535
|
+
*/
|
|
536
|
+
function pickLiteral<T extends string>(
|
|
537
|
+
v: unknown,
|
|
538
|
+
allowed: readonly T[],
|
|
539
|
+
fallback: T,
|
|
540
|
+
field: string,
|
|
541
|
+
quoted: string,
|
|
542
|
+
problems: string[],
|
|
543
|
+
): T {
|
|
544
|
+
if (v === undefined) return fallback;
|
|
545
|
+
const hit = allowed.find((a) => a === v);
|
|
546
|
+
if (hit === undefined) {
|
|
547
|
+
problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
|
|
548
|
+
return fallback;
|
|
549
|
+
}
|
|
550
|
+
return hit;
|
|
551
|
+
}
|
|
552
|
+
|
|
442
553
|
/** `~/x` in a hand-written config must not create a literal `~` directory. */
|
|
443
554
|
function expandHome(p: string): string {
|
|
444
555
|
if (p === "~") return homedir();
|