omp-conductor 0.3.5 → 0.3.7
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 +112 -6
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +18 -2
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +285 -98
- package/src/setup.ts +283 -2
- package/src/worktree.ts +19 -7
package/src/setup.ts
CHANGED
|
@@ -404,9 +404,11 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
|
|
|
404
404
|
*
|
|
405
405
|
* Split out of `buildConfig` so the plan summary can show the exact project
|
|
406
406
|
* that would be written — including the derived worktree and mirror paths —
|
|
407
|
-
* without assembling a whole config and indexing back into its array.
|
|
407
|
+
* without assembling a whole config and indexing back into its array. Exported
|
|
408
|
+
* for the amend summary's before-and-after, and so a test can pin its round-trip
|
|
409
|
+
* with {@link answersFromProject} — the pair an amend's carry-through rests on.
|
|
408
410
|
*/
|
|
409
|
-
function buildProject(a: SetupAnswers): ProjectConfig {
|
|
411
|
+
export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
410
412
|
const dir = stateDir();
|
|
411
413
|
|
|
412
414
|
const repos: Record<string, RepoTarget> = {};
|
|
@@ -486,6 +488,89 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
|
|
|
486
488
|
};
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The seed a brand-new project starts from: the shipped defaults, and nothing
|
|
493
|
+
* answered yet.
|
|
494
|
+
*
|
|
495
|
+
* Exists so "what does an unanswered field start as" has exactly one spelling.
|
|
496
|
+
* The wizard pre-fills every prompt from an answers object — this one on a first
|
|
497
|
+
* run, {@link answersFromProject} on a re-run — rather than reaching for a
|
|
498
|
+
* default at each prompt, which is how one prompt comes to disagree with the
|
|
499
|
+
* config key it writes.
|
|
500
|
+
*/
|
|
501
|
+
export function defaultAnswers(projectName: string): SetupAnswers {
|
|
502
|
+
return {
|
|
503
|
+
projectName,
|
|
504
|
+
// Empty rather than a plausible guess: both are required, and a pre-filled
|
|
505
|
+
// tracker repo is the one default an operator would Enter straight past.
|
|
506
|
+
trackerRepo: "",
|
|
507
|
+
queueLabel: SETUP_DEFAULTS.queueLabel,
|
|
508
|
+
stateLabels: { ...SETUP_DEFAULTS.stateLabels },
|
|
509
|
+
routingLabelPrefix: SETUP_DEFAULTS.routingLabelPrefix,
|
|
510
|
+
targetRepos: [],
|
|
511
|
+
caps: {},
|
|
512
|
+
fallbackToIssueComment: true,
|
|
513
|
+
authority: { ...SETUP_DEFAULTS.authority },
|
|
514
|
+
orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
|
|
515
|
+
reportScope: DEFAULT_REPORT_SCOPE,
|
|
516
|
+
writeOrchestratorBrief: false,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The answers that describe a project already on disk — the inverse of
|
|
522
|
+
* {@link buildProject}, and what makes amending one area possible.
|
|
523
|
+
*
|
|
524
|
+
* Every field is derived here, in one function, rather than field by field at
|
|
525
|
+
* each prompt: an amend asks one area's questions and carries everything else
|
|
526
|
+
* through untouched, so anything this forgets is a setting the operator loses by
|
|
527
|
+
* changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
|
|
528
|
+
* `p` by a test for exactly that reason.
|
|
529
|
+
*
|
|
530
|
+
* Three fields cannot be a straight copy:
|
|
531
|
+
*
|
|
532
|
+
* - `writeOrchestratorBrief` is a decision rather than a value, and it starts
|
|
533
|
+
* `false` so an amend that never visits the brief area leaves that file alone.
|
|
534
|
+
* - `reportScope` reads through {@link DEFAULT_REPORT_SCOPE}, because the key is
|
|
535
|
+
* optional on disk. A config written before it existed gains it explicitly on
|
|
536
|
+
* the next write, saying what it already meant.
|
|
537
|
+
* - `graphRoot` is one answer for a whole project while the config stores one
|
|
538
|
+
* path per repo, so it comes back from whichever repo already has one. Repos
|
|
539
|
+
* that disagree — only a hand-edit can produce that — widen to all of them on
|
|
540
|
+
* the next write exactly as a full re-run would, and the plan summary names
|
|
541
|
+
* every clone before anything is written.
|
|
542
|
+
*/
|
|
543
|
+
export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
544
|
+
const answers: SetupAnswers = {
|
|
545
|
+
projectName: p.name,
|
|
546
|
+
trackerRepo: p.tracker.repo,
|
|
547
|
+
queueLabel: p.queueLabel,
|
|
548
|
+
stateLabels: { ...p.stateLabels },
|
|
549
|
+
routingLabelPrefix: p.routing.labelPrefix,
|
|
550
|
+
targetRepos: Object.values(p.routing.repos).map((r) => ({
|
|
551
|
+
name: r.name,
|
|
552
|
+
cloneUrl: r.cloneUrl,
|
|
553
|
+
defaultBranch: r.defaultBranch,
|
|
554
|
+
gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
|
|
555
|
+
})),
|
|
556
|
+
caps: { ...p.caps },
|
|
557
|
+
fallbackToIssueComment: p.escalation.fallbackToIssueComment,
|
|
558
|
+
authority: { ...p.authority },
|
|
559
|
+
orchestratorMode: p.escalation.orchestrator,
|
|
560
|
+
reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
561
|
+
writeOrchestratorBrief: false,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// Set only when present, never as an explicit `undefined`: an absent key is
|
|
565
|
+
// what keeps the rewritten config identical to the one that was read.
|
|
566
|
+
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
567
|
+
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
568
|
+
const graphed = graphRepos(p)[0];
|
|
569
|
+
if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
|
|
570
|
+
|
|
571
|
+
return answers;
|
|
572
|
+
}
|
|
573
|
+
|
|
489
574
|
/**
|
|
490
575
|
* Where a configured project's brief lives: beside its worktrees, under the state
|
|
491
576
|
* directory, so it is on the same disk the fleet already owns and survives a
|
|
@@ -793,3 +878,199 @@ export function summarisePlan(
|
|
|
793
878
|
|
|
794
879
|
return lines.join("\n");
|
|
795
880
|
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Gates as the wizard both shows and reads them back: `cmd`, or `cmd @ cwd` when
|
|
884
|
+
* one runs from a subdirectory. One spelling, so the pre-filled prompt line and
|
|
885
|
+
* the amend menu's current value cannot drift apart.
|
|
886
|
+
*/
|
|
887
|
+
export function formatGates(gates: readonly { cmd: string; cwd: string }[]): string {
|
|
888
|
+
return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The wizard's questions, grouped as the areas a re-run can amend one of, in the
|
|
893
|
+
* order the full interview asks them.
|
|
894
|
+
*
|
|
895
|
+
* Data rather than a switch so the menu, the exhaustiveness of the dialog table
|
|
896
|
+
* in ./plugin.ts, and the amend summary all enumerate the same eight areas: an
|
|
897
|
+
* added area fails to compile until it has a name, a current value and a set of
|
|
898
|
+
* questions.
|
|
899
|
+
*/
|
|
900
|
+
export const AMEND_AREA_IDS = [
|
|
901
|
+
"tracker",
|
|
902
|
+
"gates",
|
|
903
|
+
"caps",
|
|
904
|
+
"graph",
|
|
905
|
+
"authority",
|
|
906
|
+
"escalation",
|
|
907
|
+
"reporting",
|
|
908
|
+
"brief",
|
|
909
|
+
] as const;
|
|
910
|
+
|
|
911
|
+
export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The pure half of amend mode: what each area is called, what choosing it asks,
|
|
915
|
+
* and what it says right now.
|
|
916
|
+
*
|
|
917
|
+
* `describe` is the reason the pick-list is worth anything — an operator picking
|
|
918
|
+
* blind from eight nouns cannot tell which one holds the setting they came to
|
|
919
|
+
* change, so every row carries its own current value. It reads only the config,
|
|
920
|
+
* so the whole menu can be rendered and reviewed without a terminal.
|
|
921
|
+
*/
|
|
922
|
+
export const AMEND_AREAS: {
|
|
923
|
+
readonly [K in AmendAreaId]: {
|
|
924
|
+
readonly name: string;
|
|
925
|
+
readonly asks: string;
|
|
926
|
+
readonly describe: (p: ProjectConfig) => string;
|
|
927
|
+
};
|
|
928
|
+
} = {
|
|
929
|
+
tracker: {
|
|
930
|
+
name: "tracker & repos",
|
|
931
|
+
asks: "tracker repo, queue and state labels, routing prefix, then every routed repo with its gates",
|
|
932
|
+
describe: (p) => {
|
|
933
|
+
const names = Object.values(p.routing.repos).map((r) => r.name);
|
|
934
|
+
return (
|
|
935
|
+
`${p.tracker.repo}, queue "${p.queueLabel}", ` +
|
|
936
|
+
`"${p.routing.labelPrefix}" → ${names.join(", ") || "no repos"}`
|
|
937
|
+
);
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
gates: {
|
|
941
|
+
name: "gates",
|
|
942
|
+
asks: "the pre-push commands for each configured repo, and nothing else",
|
|
943
|
+
describe: (p) => {
|
|
944
|
+
const repos = Object.values(p.routing.repos);
|
|
945
|
+
if (repos.length === 0) return "no repos configured";
|
|
946
|
+
return repos.map((r) => `${r.name}: ${r.gates.length === 0 ? "none" : formatGates(r.gates)}`).join("; ");
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
caps: {
|
|
950
|
+
// The model rides with the caps because it is the other per-worker knob, and
|
|
951
|
+
// an area no menu offers is a setting only a full re-interview can reach.
|
|
952
|
+
name: "caps & worker model",
|
|
953
|
+
asks: "concurrency, spend, turns, wall clock, attempts per issue — then the worker model",
|
|
954
|
+
describe: (p) => {
|
|
955
|
+
const c = resolveCaps(p, DEFAULT_CAPS);
|
|
956
|
+
const answered = Object.keys(p.caps).length > 0;
|
|
957
|
+
return (
|
|
958
|
+
`${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
|
|
959
|
+
`${Math.round(c.workerWallClockMs / 60000)}m, $${c.dailySpendUsd}/day, ` +
|
|
960
|
+
`${c.maxAttemptsPerIssue} attempts${answered ? "" : " (all defaults)"} — ` +
|
|
961
|
+
`${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
|
|
962
|
+
);
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
graph: {
|
|
966
|
+
name: "code graph",
|
|
967
|
+
asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",
|
|
968
|
+
describe: (p) => {
|
|
969
|
+
const graphed = graphRepos(p);
|
|
970
|
+
const first = graphed[0];
|
|
971
|
+
if (first === undefined) return "not configured — workers grep";
|
|
972
|
+
return `${dirname(first.graphProject)} — ${graphed.length} clone(s): ${graphed.map((r) => r.name).join(", ")}`;
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
authority: {
|
|
976
|
+
name: "authority",
|
|
977
|
+
asks: "who lands green PRs, and who cuts releases",
|
|
978
|
+
describe: (p) => `merge=${p.authority.merge}, release=${p.authority.release}`,
|
|
979
|
+
},
|
|
980
|
+
escalation: {
|
|
981
|
+
name: "escalation & triage",
|
|
982
|
+
asks: "the tier-2 Telegram chat, whether escalations also comment, and where the orchestrator session lives",
|
|
983
|
+
describe: (p) =>
|
|
984
|
+
[
|
|
985
|
+
p.escalation.telegramChatId === undefined
|
|
986
|
+
? "tier 2 by issue comment only"
|
|
987
|
+
: `tier 2 pages Telegram ${p.escalation.telegramChatId}`,
|
|
988
|
+
p.escalation.fallbackToIssueComment ? "comments too" : "no comment fallback",
|
|
989
|
+
`triage ${p.escalation.orchestrator}`,
|
|
990
|
+
].join(", "),
|
|
991
|
+
},
|
|
992
|
+
reporting: {
|
|
993
|
+
name: "reporting scope",
|
|
994
|
+
asks: "how much the orchestrator says unprompted",
|
|
995
|
+
describe: (p) => {
|
|
996
|
+
const scope = p.reporting?.scope ?? DEFAULT_REPORT_SCOPE;
|
|
997
|
+
const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
|
|
998
|
+
return `${scope} — ${choice?.description ?? "unknown scope"}`;
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
brief: {
|
|
1002
|
+
name: "orchestrator brief",
|
|
1003
|
+
asks: `whether to render ${ORCHESTRATOR_BRIEF_NAME} — the one area that writes no config key`,
|
|
1004
|
+
describe: (p) => {
|
|
1005
|
+
const path = briefPathForProject(p);
|
|
1006
|
+
return existsSync(path) ? `written at ${path}` : `none at ${path}`;
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* How much of a current value fits on a menu row before it costs more than it
|
|
1013
|
+
* tells. Chosen so a four-repo fleet's tracker row — the longest one worth
|
|
1014
|
+
* keeping whole — survives intact. The full text is never lost either way: the
|
|
1015
|
+
* amend summary prints it unelided, and the area's own prompts pre-fill from it.
|
|
1016
|
+
*/
|
|
1017
|
+
const AMEND_LABEL_MAX = 96;
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The amend pick-list, rendered.
|
|
1021
|
+
*
|
|
1022
|
+
* The label carries the current value because the harness's select resolves to
|
|
1023
|
+
* the label it showed, so the row an operator picked has to be recognisable from
|
|
1024
|
+
* its own text alone — and it is the value, not the noun, that tells them
|
|
1025
|
+
* whether this is the row they came for.
|
|
1026
|
+
*/
|
|
1027
|
+
export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
|
|
1028
|
+
return AMEND_AREA_IDS.map((id) => {
|
|
1029
|
+
const area = AMEND_AREAS[id];
|
|
1030
|
+
const current = area.describe(p);
|
|
1031
|
+
return {
|
|
1032
|
+
id,
|
|
1033
|
+
label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
|
|
1034
|
+
description: area.asks,
|
|
1035
|
+
};
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* What an amend leads its consent screen with: the area, what it said, what it
|
|
1041
|
+
* would say, and the seven areas nobody was asked about.
|
|
1042
|
+
*
|
|
1043
|
+
* The whole plan still follows this, because the confirm has to name every
|
|
1044
|
+
* mutation it authorises — creating labels, writing the config, replacing a
|
|
1045
|
+
* brief — and a delta alone names none of them. What this adds is the sentence
|
|
1046
|
+
* the operator is actually looking for: one area changed, everything else came
|
|
1047
|
+
* back off disk.
|
|
1048
|
+
*/
|
|
1049
|
+
export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: SetupAnswers): string {
|
|
1050
|
+
const it = AMEND_AREAS[area];
|
|
1051
|
+
const was = it.describe(before);
|
|
1052
|
+
// The brief is a decision, not a config key, so its "after" is what the wizard
|
|
1053
|
+
// is about to do rather than what a rebuilt project would say.
|
|
1054
|
+
const now =
|
|
1055
|
+
area === "brief"
|
|
1056
|
+
? a.writeOrchestratorBrief
|
|
1057
|
+
? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
|
|
1058
|
+
: "not written — left exactly as it is"
|
|
1059
|
+
: it.describe(buildProject(a));
|
|
1060
|
+
|
|
1061
|
+
const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
|
|
1062
|
+
const lines = [`amending ${it.name} — project ${before.name}`];
|
|
1063
|
+
if (was === now) {
|
|
1064
|
+
lines.push(` no change ${was}`, " you answered through without changing anything here");
|
|
1065
|
+
} else {
|
|
1066
|
+
lines.push(` was ${was}`, ` now ${now}`);
|
|
1067
|
+
}
|
|
1068
|
+
lines.push(
|
|
1069
|
+
` carried over ${others.join(", ")}`,
|
|
1070
|
+
` read back from ${configPath()} and rewritten unchanged`,
|
|
1071
|
+
"",
|
|
1072
|
+
"The whole project as it would then be written:",
|
|
1073
|
+
"",
|
|
1074
|
+
);
|
|
1075
|
+
return lines.join("\n");
|
|
1076
|
+
}
|
package/src/worktree.ts
CHANGED
|
@@ -102,15 +102,27 @@ const EXCLUDE_END = "# <<< omp-conductor";
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Appended to every mirror's `info/exclude`, and so in force in every worktree
|
|
105
|
-
* cut from it
|
|
106
|
-
*
|
|
105
|
+
* cut from it: a worker's own scaffolding is not the repo's business, and
|
|
106
|
+
* keeping it out of `git status` keeps it out of the worker's own `git add -A`
|
|
107
|
+
* as well as out of salvage.
|
|
107
108
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* **Only the directory form of `.scratch*`; the slash is the design.** An ignore
|
|
110
|
+
* here applies to every repo this fleet touches, and an ignored *new* file is
|
|
111
|
+
* invisible to salvage — `git add -A` skips it, and a tree holding only such
|
|
112
|
+
* files reports `nothing`. So a name that could plausibly be a deliverable must
|
|
113
|
+
* never appear in this list. The first version also carried `.env.local` and
|
|
114
|
+
* `*.local.sh`; its first correction still carried an unqualified `.scratch*`.
|
|
115
|
+
* All three make the same mistake: repos can legitimately ship an `.env.local`
|
|
116
|
+
* template, `bootstrap.local.sh`, `.scratchrc`, or `.scratchpad`, and a worker
|
|
117
|
+
* asked to add one would watch it vanish.
|
|
118
|
+
*
|
|
119
|
+
* The directory-only pattern survives because its trailing slash limits it to a
|
|
120
|
+
* directory whose name announces that its contents are disposable. That was
|
|
121
|
+
* the 2026-08-07 incident's exact shape (`.scratch82/env.sh`). Broader
|
|
122
|
+
* conventions belong in a repo's own `.gitignore`, where its operator chooses
|
|
123
|
+
* them, rather than being imposed by whatever dispatcher happens to be driving.
|
|
112
124
|
*/
|
|
113
|
-
const LOCAL_EXCLUDE = [".scratch*/"
|
|
125
|
+
const LOCAL_EXCLUDE = [".scratch*/"];
|
|
114
126
|
|
|
115
127
|
/**
|
|
116
128
|
* Adds the managed block to an `info/exclude`, preserving everything else.
|