omp-conductor 0.3.4 → 0.3.6
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 +204 -6
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +98 -5
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/worker.md +16 -5
- package/src/cli.ts +60 -0
- package/src/config.ts +40 -4
- package/src/daemon.ts +15 -1
- package/src/graph.ts +508 -0
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +329 -90
- package/src/setup.ts +315 -2
- package/src/types.ts +25 -0
- package/src/worktree.ts +81 -2
package/src/setup.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
26
|
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
27
|
+
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
27
28
|
import {
|
|
28
29
|
CONFIG_VERSION,
|
|
29
30
|
DEFAULT_AUTHORITY,
|
|
@@ -83,6 +84,17 @@ export interface SetupAnswers {
|
|
|
83
84
|
* that session to drain, rather than starting a second brain.
|
|
84
85
|
*/
|
|
85
86
|
orchestratorMode: OrchestratorMode;
|
|
87
|
+
/**
|
|
88
|
+
* Parent directory of the index-only clones workers query, or absent when the
|
|
89
|
+
* operator declined code-graph discovery — in which case no repo gets a
|
|
90
|
+
* `graphProject` and every rendered brief is the one this package shipped
|
|
91
|
+
* before graphs existed.
|
|
92
|
+
*
|
|
93
|
+
* One answer for the whole project rather than one per repo: the clones are
|
|
94
|
+
* derived data with no reason to live apart, and a per-repo prompt would ask
|
|
95
|
+
* the same question four times to arrive at four siblings.
|
|
96
|
+
*/
|
|
97
|
+
graphRoot?: string;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** What `gh auth status` says the active token may do. */
|
|
@@ -392,18 +404,27 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
|
|
|
392
404
|
*
|
|
393
405
|
* Split out of `buildConfig` so the plan summary can show the exact project
|
|
394
406
|
* that would be written — including the derived worktree and mirror paths —
|
|
395
|
-
* 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.
|
|
396
410
|
*/
|
|
397
|
-
function buildProject(a: SetupAnswers): ProjectConfig {
|
|
411
|
+
export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
398
412
|
const dir = stateDir();
|
|
399
413
|
|
|
400
414
|
const repos: Record<string, RepoTarget> = {};
|
|
415
|
+
const graphRoot = a.graphRoot?.trim();
|
|
401
416
|
for (const r of a.targetRepos) {
|
|
402
417
|
repos[r.name] = {
|
|
403
418
|
name: r.name,
|
|
404
419
|
cloneUrl: r.cloneUrl,
|
|
405
420
|
defaultBranch: r.defaultBranch,
|
|
406
421
|
gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
|
|
422
|
+
// One answered root becomes one clone per routed repo. Omitted entirely
|
|
423
|
+
// when unanswered rather than written empty: the key's absence is what
|
|
424
|
+
// makes an existing config's briefs render exactly as they did before.
|
|
425
|
+
...(graphRoot === undefined || graphRoot.length === 0
|
|
426
|
+
? {}
|
|
427
|
+
: { graphProject: graphProjectPath(graphRoot, r.name) }),
|
|
407
428
|
};
|
|
408
429
|
}
|
|
409
430
|
|
|
@@ -467,6 +488,89 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
|
|
|
467
488
|
};
|
|
468
489
|
}
|
|
469
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
|
+
|
|
470
574
|
/**
|
|
471
575
|
* Where a configured project's brief lives: beside its worktrees, under the state
|
|
472
576
|
* directory, so it is on the same disk the fleet already owns and survives a
|
|
@@ -694,6 +798,19 @@ export function summarisePlan(
|
|
|
694
798
|
}
|
|
695
799
|
}
|
|
696
800
|
|
|
801
|
+
// Absent entirely when unanswered: a plan for a project with no graph must
|
|
802
|
+
// read exactly as it did before graphs were a thing this wizard could offer.
|
|
803
|
+
const graphed = graphRepos(project);
|
|
804
|
+
if (graphed.length > 0) {
|
|
805
|
+
lines.push("", "code graph workers query these clones instead of grepping:");
|
|
806
|
+
for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`);
|
|
807
|
+
lines.push(
|
|
808
|
+
" conductor's own index-only clones — nothing human edits them, and",
|
|
809
|
+
" nothing here creates them. Run `omp-conductor graph-setup` after",
|
|
810
|
+
" setup: it prints the clone, index and systemd-timer commands.",
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
697
814
|
lines.push("", "caps (effective)");
|
|
698
815
|
for (const [key, value] of Object.entries(effective)) {
|
|
699
816
|
const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
|
|
@@ -761,3 +878,199 @@ export function summarisePlan(
|
|
|
761
878
|
|
|
762
879
|
return lines.join("\n");
|
|
763
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/types.ts
CHANGED
|
@@ -46,6 +46,31 @@ export interface RepoTarget {
|
|
|
46
46
|
* subset lets lint errors outside the source dir reach the runners.
|
|
47
47
|
*/
|
|
48
48
|
gates: { cmd: string; cwd: string }[];
|
|
49
|
+
/**
|
|
50
|
+
* Absolute path of the **conductor-owned, index-only clone** of this repo
|
|
51
|
+
* whose code-graph index workers query. Optional: absent means this repo has
|
|
52
|
+
* no graph, and the worker brief says nothing about one.
|
|
53
|
+
*
|
|
54
|
+
* Two things it deliberately is not, and both were paid for:
|
|
55
|
+
*
|
|
56
|
+
* - **Not a worker's worktree.** A code-graph index is keyed by the realpath
|
|
57
|
+
* of the directory it was built from, with no git-worktree awareness, so a
|
|
58
|
+
* run's throwaway `worktrees/<issue>` path is always an empty project. A
|
|
59
|
+
* worker that queried its own cwd would find nothing, conclude there is no
|
|
60
|
+
* graph, and go back to grepping — which is the entire cost this field
|
|
61
|
+
* exists to remove.
|
|
62
|
+
* - **Not a human's checkout.** Refreshing an index means hard-resetting the
|
|
63
|
+
* clone to its default branch. Doing that where somebody works destroys
|
|
64
|
+
* their uncommitted edits; making it safe instead (a fast-forward pull)
|
|
65
|
+
* means the graph reflects whatever feature branch they left checked out.
|
|
66
|
+
* So this names a disposable clone nothing human ever edits, which is what
|
|
67
|
+
* makes the reset both safe and deterministic.
|
|
68
|
+
*
|
|
69
|
+
* `omp-conductor graph-setup` prints how to create and refresh it. Nothing in
|
|
70
|
+
* this package reads an index itself: the daemon only passes this path into
|
|
71
|
+
* the worker brief.
|
|
72
|
+
*/
|
|
73
|
+
graphProject?: string;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/**
|
package/src/worktree.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* for the delta.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
14
|
import type { RepoTarget } from "./types.ts";
|
|
15
15
|
|
|
@@ -96,6 +96,50 @@ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
|
|
|
96
96
|
return code === 0;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Fences the block below so it can be found, replaced, and never duplicated. */
|
|
100
|
+
const EXCLUDE_BEGIN = "# >>> omp-conductor (managed; edit outside this block)";
|
|
101
|
+
const EXCLUDE_END = "# <<< omp-conductor";
|
|
102
|
+
|
|
103
|
+
/**
|
|
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.
|
|
107
|
+
*
|
|
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`.
|
|
112
|
+
*/
|
|
113
|
+
const LOCAL_EXCLUDE = [".scratch*/", ".scratch*", ".env.local", "*.local.sh"];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Adds the managed block to an `info/exclude`, preserving everything else.
|
|
117
|
+
*
|
|
118
|
+
* `info/exclude` is a *local* ignore file, which means it is exactly where an
|
|
119
|
+
* operator or another tool puts patterns they could not put in the tracked
|
|
120
|
+
* `.gitignore` — so overwriting it would silently destroy work that has no
|
|
121
|
+
* other copy. The block is fenced and replaced in place, so re-running this on
|
|
122
|
+
* every dispatch neither duplicates our lines nor disturbs theirs.
|
|
123
|
+
*/
|
|
124
|
+
export function mergeExclude(existing: string): string {
|
|
125
|
+
const begin = existing.indexOf(EXCLUDE_BEGIN);
|
|
126
|
+
const end = existing.indexOf(EXCLUDE_END);
|
|
127
|
+
const theirs =
|
|
128
|
+
begin === -1 || end === -1 || end < begin
|
|
129
|
+
? existing
|
|
130
|
+
: existing.slice(0, begin) + existing.slice(end + EXCLUDE_END.length + 1);
|
|
131
|
+
|
|
132
|
+
// Normalised before recomposing, so the result is byte-identical on every
|
|
133
|
+
// call. Trimming the tail matters twice over: a hand-edited file often has no
|
|
134
|
+
// trailing newline (the first managed line would glue onto their last
|
|
135
|
+
// pattern and match nothing), and without it the blank separator below would
|
|
136
|
+
// accumulate one more newline on each of the thousands of dispatches that
|
|
137
|
+
// call this.
|
|
138
|
+
const body = theirs.replace(/\n+$/, "");
|
|
139
|
+
const head = body === "" ? [] : [body, ""];
|
|
140
|
+
return [...head, EXCLUDE_BEGIN, ...LOCAL_EXCLUDE, EXCLUDE_END, ""].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
/**
|
|
100
144
|
* Rewrites the two `clone --mirror` defaults that are actively dangerous for a
|
|
101
145
|
* cache we cut worktrees from. Applied on every `ensureMirror` so a mirror left
|
|
@@ -122,6 +166,20 @@ async function configureMirror(mirrorPath: string): Promise<void> {
|
|
|
122
166
|
["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC],
|
|
123
167
|
mirrorPath,
|
|
124
168
|
);
|
|
169
|
+
|
|
170
|
+
// A mirror's `info/exclude` is the common git dir for every worktree cut from
|
|
171
|
+
// it, so one write here keeps a worker's own scratch out of `git status` in
|
|
172
|
+
// all of them — without touching the repo's tracked `.gitignore`, which is
|
|
173
|
+
// the operator's file and not ours to edit.
|
|
174
|
+
//
|
|
175
|
+
// Belt and braces with the salvage excludes rather than a replacement for
|
|
176
|
+
// them: this stops scratch reaching a *worker's* own `git add`, which salvage
|
|
177
|
+
// never sees. Merged rather than written, because `info/exclude` is precisely
|
|
178
|
+
// where an operator keeps patterns that cannot go in the tracked file — and
|
|
179
|
+
// this runs on every dispatch, so overwriting would destroy them repeatedly.
|
|
180
|
+
const exclude = join(mirrorPath, "info", "exclude");
|
|
181
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
182
|
+
writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
|
|
125
183
|
}
|
|
126
184
|
|
|
127
185
|
/**
|
|
@@ -343,7 +401,28 @@ export async function salvageWip(
|
|
|
343
401
|
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
344
402
|
|
|
345
403
|
// `-A` on purpose: the losses this exists for were mostly *new* files.
|
|
404
|
+
//
|
|
405
|
+
// Filtering scratch is git's job, not a pathspec's. The mirror's
|
|
406
|
+
// `info/exclude` (see {@link mergeExclude}) is the common git dir for this
|
|
407
|
+
// worktree, and git's own rules then give exactly the semantics needed:
|
|
408
|
+
// untracked ignored files are skipped, while modifications to *tracked*
|
|
409
|
+
// files are staged even when the name matches an ignore. That second half
|
|
410
|
+
// is why an exclude pathspec here was wrong — it matched on filename alone,
|
|
411
|
+
// so a repo legitimately versioning a `bootstrap.local.sh` would have lost
|
|
412
|
+
// a worker's edits to it, salvage destroying the work it exists to save.
|
|
413
|
+
//
|
|
414
|
+
// A literal `:(exclude)<path>` was also an outright bug: git counts it as
|
|
415
|
+
// naming the path, so an already-ignored file made `add` exit 1 and every
|
|
416
|
+
// cap-kill would have reported a salvage *failure*.
|
|
346
417
|
await git(["add", "-A"], worktree);
|
|
418
|
+
|
|
419
|
+
// The dirty check above ran before git applied its ignores, so a tree whose
|
|
420
|
+
// only changes were ignored scratch had work by that test and none by this.
|
|
421
|
+
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
422
|
+
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
423
|
+
if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
|
|
424
|
+
return { kind: "nothing" };
|
|
425
|
+
}
|
|
347
426
|
await git(
|
|
348
427
|
[
|
|
349
428
|
...SALVAGE_COMMIT_CONFIG,
|