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/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. Deliberately the same shapes salvage therefore skips, and
106
- * for the same reason a worker's own scaffolding is not the repo's business.
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
- * Two layers because they catch different moments: this one keeps scratch out
109
- * of a worker's own `git add -A` and out of its `git status`, which salvage
110
- * never observes; the salvage list catches whatever a worker created before
111
- * this landed, or wrote past an ignore with `add -f`.
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*/", ".scratch*", ".env.local", "*.local.sh"];
125
+ const LOCAL_EXCLUDE = [".scratch*/"];
114
126
 
115
127
  /**
116
128
  * Adds the managed block to an `info/exclude`, preserving everything else.