omp-conductor 0.3.9 → 0.3.12
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 +24 -35
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +7 -6
- package/src/brief-upgrade.ts +357 -54
- package/src/briefs/orchestrator.md +50 -127
- package/src/briefs/policy.md +89 -0
- package/src/cli.ts +114 -24
- package/src/daemon.ts +18 -9
- package/src/escalate.ts +26 -4
- package/src/orchestrator-tick.ts +90 -8
- package/src/plugin.ts +59 -16
- package/src/setup.ts +104 -41
- package/src/worktree.ts +67 -10
package/src/orchestrator-tick.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Self-tick for the fleet orchestrator session.
|
|
3
3
|
*
|
|
4
|
-
* The orchestrator is a 24/7 omp session with a standing brief (ORCHESTRATOR.md)
|
|
4
|
+
* The orchestrator is a 24/7 omp session with a standing brief (composed ORCHESTRATOR.md + POLICY.md)
|
|
5
5
|
* and no user typing into it. A session that is never prompted never runs its
|
|
6
6
|
* loop, so this extension is the heartbeat: every `intervalSeconds` it injects
|
|
7
7
|
* one message that starts a turn.
|
|
@@ -44,6 +44,11 @@ import { spawnSync } from "node:child_process";
|
|
|
44
44
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { isAbsolute, join, resolve } from "node:path";
|
|
46
46
|
import { findProject, loadConfig } from "./config.ts";
|
|
47
|
+
import {
|
|
48
|
+
briefPathForProject,
|
|
49
|
+
policyPathForProject,
|
|
50
|
+
refreshComposedBriefForProject,
|
|
51
|
+
} from "./setup.ts";
|
|
47
52
|
import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
|
|
48
53
|
|
|
49
54
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
@@ -94,6 +99,14 @@ const RETRY_OWNERSHIP_MS = 60_000;
|
|
|
94
99
|
export const STALL_MARKER_FILE = ".conductor-stalled";
|
|
95
100
|
export const STALL_TICKS = 2;
|
|
96
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
|
|
104
|
+
* fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
|
|
105
|
+
* only after a tick is actually sent — a disarmed or channel-down fleet keeps
|
|
106
|
+
* the request until gates pass (or a human removes the file).
|
|
107
|
+
*/
|
|
108
|
+
export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
|
|
109
|
+
|
|
97
110
|
/**
|
|
98
111
|
* The marker's one line after its ISO timestamp, and the middle of the error
|
|
99
112
|
* log. Shared so the file and the log can never describe different failures.
|
|
@@ -229,8 +242,16 @@ export type TickConfigResult =
|
|
|
229
242
|
* A second spelling of it here would contradict the first inside one prompt the
|
|
230
243
|
* moment a fleet chose `escalations`.
|
|
231
244
|
*/
|
|
232
|
-
export function defaultTickMessage(
|
|
233
|
-
|
|
245
|
+
export function defaultTickMessage(
|
|
246
|
+
now: Date,
|
|
247
|
+
briefPath = "ORCHESTRATOR.md",
|
|
248
|
+
policyPath = "POLICY.md",
|
|
249
|
+
): string {
|
|
250
|
+
return (
|
|
251
|
+
`Tick ${now.toISOString()}: re-read ${briefPath} (composed package floor + policy) and ` +
|
|
252
|
+
`${policyPath} (editable fleet policy) from disk, then run your standing loop from them. ` +
|
|
253
|
+
`Learning-loop amendments edit only ${policyPath} — never the package floor.`
|
|
254
|
+
);
|
|
234
255
|
}
|
|
235
256
|
|
|
236
257
|
/**
|
|
@@ -288,18 +309,43 @@ export const TICK_DELIVERY_RULE =
|
|
|
288
309
|
* `status`. Stopping the heartbeat over either preference would be the worse
|
|
289
310
|
* trade.
|
|
290
311
|
*/
|
|
291
|
-
export function resolveTickScope(): {
|
|
312
|
+
export function resolveTickScope(): {
|
|
313
|
+
scope: ReportScope;
|
|
314
|
+
briefPath?: string;
|
|
315
|
+
policyPath?: string;
|
|
316
|
+
projectName?: string;
|
|
317
|
+
fallback?: string;
|
|
318
|
+
} {
|
|
292
319
|
try {
|
|
293
320
|
const project = findProject(loadConfig());
|
|
294
321
|
return {
|
|
295
322
|
scope: project.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
296
|
-
briefPath:
|
|
323
|
+
briefPath: briefPathForProject(project),
|
|
324
|
+
policyPath: policyPathForProject(project),
|
|
325
|
+
projectName: project.name,
|
|
297
326
|
};
|
|
298
327
|
} catch (err) {
|
|
299
328
|
return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
|
|
300
329
|
}
|
|
301
330
|
}
|
|
302
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Best-effort recompose of `ORCHESTRATOR.md` from the installed package floor +
|
|
334
|
+
* live `POLICY.md`.
|
|
335
|
+
*
|
|
336
|
+
* Runs on **every** successful send — including ticks that use a custom
|
|
337
|
+
* `message` — so protocol updates land after `npm install` without waiting for
|
|
338
|
+
* the default prompt path. Failures (no config, no `POLICY.md`, unreadable
|
|
339
|
+
* overlay) are silent: the tick still goes out.
|
|
340
|
+
*/
|
|
341
|
+
export function refreshComposedBriefBestEffort(): boolean {
|
|
342
|
+
try {
|
|
343
|
+
return refreshComposedBriefForProject(findProject(loadConfig()));
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
303
349
|
/**
|
|
304
350
|
* An optional file-path field. Relative paths resolve against the session cwd
|
|
305
351
|
* so `state/armed` means what it looks like; a present-but-unusable value is a
|
|
@@ -834,6 +880,23 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
|
|
|
834
880
|
}
|
|
835
881
|
}
|
|
836
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
|
|
885
|
+
* failed unlink means the next successful send retries the clear; that is
|
|
886
|
+
* preferable to treating a recover poke as fire-and-forget when the tick did
|
|
887
|
+
* land.
|
|
888
|
+
*/
|
|
889
|
+
function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
890
|
+
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
891
|
+
if (!existsSync(path)) return;
|
|
892
|
+
try {
|
|
893
|
+
rmSync(path, { force: true });
|
|
894
|
+
pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
|
|
895
|
+
} catch (err) {
|
|
896
|
+
pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
837
900
|
/** Everything one tick remembers for the next. */
|
|
838
901
|
interface TickSession {
|
|
839
902
|
/**
|
|
@@ -878,6 +941,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
878
941
|
return;
|
|
879
942
|
}
|
|
880
943
|
|
|
944
|
+
// Floor refresh is independent of which prompt we send: a custom message still
|
|
945
|
+
// expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
|
|
946
|
+
refreshComposedBriefBestEffort();
|
|
947
|
+
|
|
881
948
|
// A configured message owns the whole contract, reporting and delivery clauses
|
|
882
949
|
// included: an operator who wrote their own prompt did not ask for ours
|
|
883
950
|
// appended to it.
|
|
@@ -888,7 +955,7 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
888
955
|
session.scopeFallbackLogged = true;
|
|
889
956
|
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
890
957
|
}
|
|
891
|
-
content = `${defaultTickMessage(new Date(), scope.briefPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
|
|
958
|
+
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
|
|
892
959
|
}
|
|
893
960
|
|
|
894
961
|
pi.sendMessage(
|
|
@@ -900,6 +967,21 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
900
967
|
// this is the only place either the counter or the marker is cleared.
|
|
901
968
|
session.pendingSkips = 0;
|
|
902
969
|
clearStallMarker(pi, ctx.cwd);
|
|
970
|
+
// Recover poke is consumed only on a real send — gates still apply above.
|
|
971
|
+
clearTickRequest(pi, ctx.cwd);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Arm the interval heartbeat, then honour a recover poke if one is waiting.
|
|
976
|
+
* Extracted so the ownership-retry path and the immediate-accept path cannot
|
|
977
|
+
* drift: both must fire the same "do not wait a full interval after resume"
|
|
978
|
+
* behaviour.
|
|
979
|
+
*/
|
|
980
|
+
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
981
|
+
ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
|
|
982
|
+
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
983
|
+
pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
|
|
984
|
+
tick(pi, ctx, config, session);
|
|
903
985
|
}
|
|
904
986
|
|
|
905
987
|
export default function orchestratorTickExtension(pi: TickApi): void {
|
|
@@ -995,7 +1077,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
995
1077
|
return;
|
|
996
1078
|
}
|
|
997
1079
|
if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
|
|
998
|
-
|
|
1080
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
999
1081
|
pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
|
|
1000
1082
|
}, retryMs);
|
|
1001
1083
|
decided = true;
|
|
@@ -1008,7 +1090,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1008
1090
|
return;
|
|
1009
1091
|
}
|
|
1010
1092
|
if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
|
|
1011
|
-
|
|
1093
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
1012
1094
|
decided = true;
|
|
1013
1095
|
// Both gates are named at startup: "why is it not ticking?" is answered by
|
|
1014
1096
|
// looking at the files this line lists, and an unset channel gate on a fleet
|
package/src/plugin.ts
CHANGED
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
* worth protecting is on `setup()` below — nothing is written before the confirm.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
-
import { isAbsolute } from "node:path";
|
|
15
|
-
import {
|
|
14
|
+
import { dirname, isAbsolute } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
checkBrief,
|
|
17
|
+
formatBriefStatus,
|
|
18
|
+
formatMigrateResult,
|
|
19
|
+
inspectBriefLayout,
|
|
20
|
+
migrateToPolicy,
|
|
21
|
+
writeMergedBrief,
|
|
22
|
+
} from "./brief-upgrade.ts";
|
|
16
23
|
import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
17
24
|
import {
|
|
18
25
|
armConductor,
|
|
@@ -27,11 +34,14 @@ import { defaultGraphRoot } from "./graph.ts";
|
|
|
27
34
|
import {
|
|
28
35
|
AMEND_AREAS,
|
|
29
36
|
ORCHESTRATOR_BRIEF_NAME,
|
|
37
|
+
POLICY_BRIEF_NAME,
|
|
30
38
|
REPORT_SCOPE_CHOICES,
|
|
31
39
|
SETUP_DEFAULTS,
|
|
32
40
|
amendChoices,
|
|
33
41
|
answersFromProject,
|
|
34
42
|
briefPathForProject,
|
|
43
|
+
policyPathForProject,
|
|
44
|
+
renderFloorForProject,
|
|
35
45
|
buildConfig,
|
|
36
46
|
checkTokenScopes,
|
|
37
47
|
createMissingLabels,
|
|
@@ -374,17 +384,17 @@ async function askGraphRoot(
|
|
|
374
384
|
async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promise<boolean> {
|
|
375
385
|
const path = orchestratorBriefPath(a);
|
|
376
386
|
const wanted = await ctx.ui.confirm(
|
|
377
|
-
`Write
|
|
378
|
-
`
|
|
379
|
-
`
|
|
380
|
-
`The conductor
|
|
387
|
+
`Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
|
|
388
|
+
`Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
|
|
389
|
+
`(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
|
|
390
|
+
`The conductor stops at green PRs either way.`,
|
|
381
391
|
);
|
|
382
392
|
if (!wanted) return false;
|
|
383
393
|
if (!existsSync(path)) return true;
|
|
384
394
|
|
|
385
395
|
return await ctx.ui.confirm(
|
|
386
|
-
`Overwrite
|
|
387
|
-
`${path} already exists. Overwriting replaces
|
|
396
|
+
`Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
|
|
397
|
+
`${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
|
|
388
398
|
);
|
|
389
399
|
}
|
|
390
400
|
|
|
@@ -885,7 +895,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
885
895
|
`Wrote ${path} and armed the conductor.`,
|
|
886
896
|
briefPath === undefined
|
|
887
897
|
? "No orchestrator brief written — the conductor stops at green PRs; merges and releases stay human."
|
|
888
|
-
: `Wrote ${briefPath} — edit
|
|
898
|
+
: `Wrote ${briefPath} + POLICY.md — edit POLICY.md (Releases/Reporting); floor refreshes each tick.`,
|
|
889
899
|
"",
|
|
890
900
|
"Dry run against the config just written:",
|
|
891
901
|
...(await tryPreview(answers.projectName)),
|
|
@@ -929,18 +939,51 @@ export default function conductorPlugin(pi: PluginApi): void {
|
|
|
929
939
|
case "brief-upgrade": {
|
|
930
940
|
const p = findProject(loadConfig(), project);
|
|
931
941
|
const path = briefPathForProject(p);
|
|
932
|
-
|
|
942
|
+
const rendered = renderBriefForProject(p);
|
|
943
|
+
const layout = inspectBriefLayout(p.workspaceRoot, rendered);
|
|
944
|
+
if (layout.kind === "missing") {
|
|
945
|
+
ctx.ui.notify(
|
|
946
|
+
`No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME}.`,
|
|
947
|
+
"warning",
|
|
948
|
+
);
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
if (layout.kind === "overlay") {
|
|
933
952
|
ctx.ui.notify(
|
|
934
|
-
|
|
953
|
+
formatBriefStatus(path, {
|
|
954
|
+
kind: "overlay",
|
|
955
|
+
policyPath: layout.policyPath,
|
|
956
|
+
orchestratorPath: layout.orchestratorPath,
|
|
957
|
+
}),
|
|
958
|
+
"info",
|
|
959
|
+
);
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
if (layout.kind === "legacy-bannered") {
|
|
963
|
+
ctx.ui.notify(
|
|
964
|
+
[
|
|
965
|
+
`Legacy bannered brief at ${layout.orchestratorPath}.`,
|
|
966
|
+
"Migrate the owned half into POLICY.md so the package floor refreshes each tick.",
|
|
967
|
+
].join("\n"),
|
|
935
968
|
"warning",
|
|
936
969
|
);
|
|
970
|
+
const migrate = await ctx.ui.confirm(
|
|
971
|
+
"Migrate to POLICY.md overlay?",
|
|
972
|
+
"Writes POLICY.md from everything below YOURS TO EDIT, recomposes ORCHESTRATOR.md from the package floor + that policy, and keeps backups.",
|
|
973
|
+
);
|
|
974
|
+
if (migrate) {
|
|
975
|
+
const result = migrateToPolicy({
|
|
976
|
+
orchestratorPath: layout.orchestratorPath,
|
|
977
|
+
policyPath: policyPathForProject(p),
|
|
978
|
+
floor: renderFloorForProject(p),
|
|
979
|
+
owned: layout.owned,
|
|
980
|
+
});
|
|
981
|
+
ctx.ui.notify(formatMigrateResult(result), "info");
|
|
982
|
+
}
|
|
937
983
|
break;
|
|
938
984
|
}
|
|
939
|
-
const status = checkBrief(readFileSync(path, "utf8"),
|
|
940
|
-
ctx.ui.notify(formatBriefStatus(path, status),
|
|
941
|
-
// Confirmed here rather than applied on sight: this file is a standing
|
|
942
|
-
// prompt the operator may have spent an hour on, so the diff they just
|
|
943
|
-
// read is the thing they are agreeing to.
|
|
985
|
+
const status = checkBrief(readFileSync(path, "utf8"), rendered);
|
|
986
|
+
ctx.ui.notify(formatBriefStatus(path, status), "warning");
|
|
944
987
|
if (status.kind === "mergeable") {
|
|
945
988
|
const apply = await ctx.ui.confirm(
|
|
946
989
|
"Upgrade the brief?",
|
package/src/setup.ts
CHANGED
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
COMPOSE_BANNER,
|
|
28
|
+
ORCHESTRATOR_BRIEF_NAME,
|
|
29
|
+
POLICY_BRIEF_NAME,
|
|
30
|
+
composeOrchestrator,
|
|
31
|
+
policyPathForRoot,
|
|
32
|
+
renderBriefTemplate,
|
|
33
|
+
writeWithBackup,
|
|
34
|
+
} from "./brief-upgrade.ts";
|
|
26
35
|
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
27
36
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
28
37
|
import {
|
|
@@ -37,7 +46,6 @@ import {
|
|
|
37
46
|
type ReportScope,
|
|
38
47
|
type RepoTarget,
|
|
39
48
|
} from "./types.ts";
|
|
40
|
-
import { renderBrief } from "./worker.ts";
|
|
41
49
|
|
|
42
50
|
/**
|
|
43
51
|
* Every decision the wizard needs, in one plain object. Collected by the UI,
|
|
@@ -197,12 +205,14 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
|
|
|
197
205
|
" one is a hard boundary, not a preference.",
|
|
198
206
|
};
|
|
199
207
|
|
|
200
|
-
|
|
201
|
-
export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
|
|
208
|
+
export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
|
|
202
209
|
|
|
203
|
-
/** Shipped
|
|
210
|
+
/** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
|
|
204
211
|
const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
|
|
205
212
|
|
|
213
|
+
/** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */
|
|
214
|
+
const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
|
|
215
|
+
|
|
206
216
|
/**
|
|
207
217
|
* `repo` writes labels and closes issues; `project` moves cards on the board.
|
|
208
218
|
* Both are load-bearing for an unattended loop, so a missing one is reported
|
|
@@ -572,46 +582,69 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
572
582
|
}
|
|
573
583
|
|
|
574
584
|
/**
|
|
575
|
-
* Where a configured project's brief lives: beside its worktrees, under
|
|
576
|
-
* directory, so it is on the same disk the fleet already owns and
|
|
577
|
-
* reinstall of the package. Derived from the project rather than
|
|
578
|
-
* project that ever gains a chosen workspace root keeps its brief
|
|
585
|
+
* Where a configured project's composed brief lives: beside its worktrees, under
|
|
586
|
+
* the state directory, so it is on the same disk the fleet already owns and
|
|
587
|
+
* survives a reinstall of the package. Derived from the project rather than
|
|
588
|
+
* fixed, so a project that ever gains a chosen workspace root keeps its brief
|
|
589
|
+
* with it.
|
|
579
590
|
*/
|
|
580
591
|
export function briefPathForProject(p: ProjectConfig): string {
|
|
581
592
|
return join(p.workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
|
|
582
593
|
}
|
|
583
594
|
|
|
584
|
-
/**
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
* Exported for the upgrade check, which has to be able to read the shipped text
|
|
588
|
-
* on a host that has no config to render it against.
|
|
589
|
-
*/
|
|
590
|
-
export function shippedBriefTemplate(): string {
|
|
591
|
-
return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
|
|
595
|
+
/** Where the fleet-owned POLICY.md overlay lives for a project. */
|
|
596
|
+
export function policyPathForProject(p: ProjectConfig): string {
|
|
597
|
+
return policyPathForRoot(p.workspaceRoot);
|
|
592
598
|
}
|
|
593
599
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
*
|
|
597
|
-
* Only the coordinates, the chosen scope and the authority paragraph are
|
|
598
|
-
* substituted: the rest of the policy text is left exactly as shipped, because
|
|
599
|
-
* from here on the file is the operator's to edit and nothing in this package
|
|
600
|
-
* reads it back.
|
|
601
|
-
*
|
|
602
|
-
* Takes a `ProjectConfig` rather than answers so that a *later* upgrade check can
|
|
603
|
-
* reproduce the same render from what is on disk, months after the wizard's
|
|
604
|
-
* answers are gone.
|
|
605
|
-
*/
|
|
606
|
-
export function renderBriefForProject(p: ProjectConfig): string {
|
|
607
|
-
return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
|
|
600
|
+
function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
601
|
+
return {
|
|
608
602
|
PROJECT: p.name,
|
|
609
603
|
TRACKER_REPO: p.tracker.repo,
|
|
610
604
|
QUEUE_LABEL: p.queueLabel,
|
|
611
605
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
612
606
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
613
607
|
REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
614
|
-
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Package floor template, placeholders and all. */
|
|
612
|
+
export function shippedFloorTemplate(): string {
|
|
613
|
+
return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** POLICY.md scaffold template, placeholders and all. */
|
|
617
|
+
export function shippedPolicyTemplate(): string {
|
|
618
|
+
return readFileSync(POLICY_TEMPLATE_PATH, "utf8");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Floor + policy templates concatenated with the compose banner, placeholders
|
|
623
|
+
* intact. Used when no project config is available to render coordinates.
|
|
624
|
+
*/
|
|
625
|
+
export function shippedBriefTemplate(): string {
|
|
626
|
+
return composeOrchestrator(shippedFloorTemplate(), shippedPolicyTemplate());
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Rendered package floor for a configured project. */
|
|
630
|
+
export function renderFloorForProject(p: ProjectConfig): string {
|
|
631
|
+
return renderBriefTemplate(shippedFloorTemplate(), briefVarsForProject(p));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Rendered POLICY.md scaffold for a configured project. */
|
|
635
|
+
export function renderPolicyForProject(p: ProjectConfig): string {
|
|
636
|
+
return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Composed session brief: rendered floor + POLICY scaffold (or a caller's policy).
|
|
641
|
+
*
|
|
642
|
+
* Setup writes the policy half to `POLICY.md` and this compose to
|
|
643
|
+
* `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md so package
|
|
644
|
+
* floor updates apply without brief-upgrade.
|
|
645
|
+
*/
|
|
646
|
+
export function renderBriefForProject(p: ProjectConfig, policyText?: string): string {
|
|
647
|
+
return composeOrchestrator(renderFloorForProject(p), policyText ?? renderPolicyForProject(p));
|
|
615
648
|
}
|
|
616
649
|
|
|
617
650
|
/** Wizard-time path, via the project the answers describe. */
|
|
@@ -625,7 +658,7 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
|
|
|
625
658
|
}
|
|
626
659
|
|
|
627
660
|
/**
|
|
628
|
-
* Writes
|
|
661
|
+
* Writes `POLICY.md` + composed `ORCHESTRATOR.md`, and returns the composed path.
|
|
629
662
|
*
|
|
630
663
|
* Unconditional by design: the "do not clobber my edits" decision belongs to the
|
|
631
664
|
* operator, is asked in the wizard, and arrives here as
|
|
@@ -634,10 +667,39 @@ export function renderOrchestratorBrief(a: SetupAnswers): string {
|
|
|
634
667
|
* must get an overwrite.
|
|
635
668
|
*/
|
|
636
669
|
export function writeOrchestratorBrief(a: SetupAnswers): string {
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
670
|
+
const project = buildProject(a);
|
|
671
|
+
const policyPath = policyPathForProject(project);
|
|
672
|
+
const orchestratorPath = briefPathForProject(project);
|
|
673
|
+
mkdirSync(dirname(orchestratorPath), { recursive: true });
|
|
674
|
+
const policy = renderPolicyForProject(project);
|
|
675
|
+
writeFileSync(policyPath, policy);
|
|
676
|
+
writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy));
|
|
677
|
+
return orchestratorPath;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Recompose `ORCHESTRATOR.md` from the package floor + live `POLICY.md`.
|
|
682
|
+
*
|
|
683
|
+
* Returns false when POLICY.md is missing (caller should migrate or set up).
|
|
684
|
+
*/
|
|
685
|
+
export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
|
|
686
|
+
const policyPath = policyPathForProject(p);
|
|
687
|
+
if (!existsSync(policyPath)) return false;
|
|
688
|
+
const orchestratorPath = briefPathForProject(p);
|
|
689
|
+
mkdirSync(dirname(orchestratorPath), { recursive: true });
|
|
690
|
+
writeFileSync(
|
|
691
|
+
orchestratorPath,
|
|
692
|
+
composeOrchestrator(renderFloorForProject(p), readFileSync(policyPath, "utf8")),
|
|
693
|
+
);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @internal test helper — expose compose banner for assertions. */
|
|
698
|
+
export const BRIEF_COMPOSE_BANNER = COMPOSE_BANNER;
|
|
699
|
+
|
|
700
|
+
/** Backup-aware POLICY write used by migrate paths that already computed text. */
|
|
701
|
+
export function writePolicyFile(path: string, content: string): string | undefined {
|
|
702
|
+
return writeWithBackup(path, content);
|
|
641
703
|
}
|
|
642
704
|
|
|
643
705
|
/**
|
|
@@ -861,11 +923,12 @@ export function summarisePlan(
|
|
|
861
923
|
` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
|
|
862
924
|
);
|
|
863
925
|
if (a.writeOrchestratorBrief) {
|
|
926
|
+
const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
|
|
864
927
|
lines.push(
|
|
865
|
-
existsSync(briefPath)
|
|
866
|
-
? ` brief would OVERWRITE ${briefPath}`
|
|
867
|
-
: ` brief would write ${briefPath}`,
|
|
868
|
-
"
|
|
928
|
+
existsSync(briefPath) || existsSync(policyPath)
|
|
929
|
+
? ` brief would OVERWRITE ${briefPath} + POLICY.md`
|
|
930
|
+
: ` brief would write ${briefPath} + POLICY.md`,
|
|
931
|
+
" POLICY.md is yours — Releases/Reporting/Amendments; floor recomposes each tick",
|
|
869
932
|
);
|
|
870
933
|
} else {
|
|
871
934
|
lines.push(
|
|
@@ -1002,7 +1065,7 @@ export const AMEND_AREAS: {
|
|
|
1002
1065
|
},
|
|
1003
1066
|
brief: {
|
|
1004
1067
|
name: "orchestrator brief",
|
|
1005
|
-
asks: `whether to
|
|
1068
|
+
asks: `whether to write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} — the one area that writes no config key`,
|
|
1006
1069
|
describe: (p) => {
|
|
1007
1070
|
const path = briefPathForProject(p);
|
|
1008
1071
|
return existsSync(path) ? `written at ${path}` : `none at ${path}`;
|
package/src/worktree.ts
CHANGED
|
@@ -379,7 +379,17 @@ export async function addWorktree(
|
|
|
379
379
|
* none of that may be skipped by an exception from the last-ditch step.
|
|
380
380
|
*/
|
|
381
381
|
export type SalvageOutcome =
|
|
382
|
-
| {
|
|
382
|
+
| {
|
|
383
|
+
kind: "salvaged";
|
|
384
|
+
sha: string;
|
|
385
|
+
branch: string;
|
|
386
|
+
pushed: boolean;
|
|
387
|
+
pushError?: string;
|
|
388
|
+
/** Paths in the salvage commit (count = length). */
|
|
389
|
+
files: string[];
|
|
390
|
+
/** Subset that were untracked before salvage (`A` in the commit). */
|
|
391
|
+
newPaths: string[];
|
|
392
|
+
}
|
|
383
393
|
/** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
|
|
384
394
|
| { kind: "nothing" }
|
|
385
395
|
/** There was work and git would not commit it. This is the loud one. */
|
|
@@ -403,6 +413,51 @@ const SALVAGE_COMMIT_CONFIG = [
|
|
|
403
413
|
"commit.gpgsign=false",
|
|
404
414
|
];
|
|
405
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Subject stays the historical one-liner so status greps keep working; the
|
|
418
|
+
* body is the manifest (#38). Cap the new-path list so a runaway tree cannot
|
|
419
|
+
* push a multi-kilobyte commit message into every escalation.
|
|
420
|
+
*/
|
|
421
|
+
export function salvageCommitMessage(
|
|
422
|
+
issue: number,
|
|
423
|
+
attempt: number,
|
|
424
|
+
reason: string,
|
|
425
|
+
files: string[],
|
|
426
|
+
newPaths: string[],
|
|
427
|
+
): { subject: string; body: string } {
|
|
428
|
+
const subject = `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`;
|
|
429
|
+
const n = files.length;
|
|
430
|
+
const count = `${n} file${n === 1 ? "" : "s"}`;
|
|
431
|
+
if (newPaths.length === 0) {
|
|
432
|
+
return { subject, body: `${count} (all modifications to tracked paths).` };
|
|
433
|
+
}
|
|
434
|
+
const cap = 30;
|
|
435
|
+
const shown = newPaths.slice(0, cap);
|
|
436
|
+
const more = newPaths.length - shown.length;
|
|
437
|
+
const list = shown.map((f) => ` ${f}`).join("\n");
|
|
438
|
+
const suffix = more > 0 ? `\n … and ${more} more` : "";
|
|
439
|
+
return {
|
|
440
|
+
subject,
|
|
441
|
+
body: `${count}. New (untracked before salvage):\n${list}${suffix}`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string[] } {
|
|
446
|
+
const files: string[] = [];
|
|
447
|
+
const newPaths: string[] = [];
|
|
448
|
+
for (const line of raw.split("\n")) {
|
|
449
|
+
if (line === "") continue;
|
|
450
|
+
// name-status: "<status>\t<path>" or rename "R100\t<old>\t<new>"
|
|
451
|
+
const parts = line.split("\t");
|
|
452
|
+
const status = parts[0] ?? "";
|
|
453
|
+
const path = parts.length >= 3 ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
|
|
454
|
+
if (path === "") continue;
|
|
455
|
+
files.push(path);
|
|
456
|
+
if (status.startsWith("A")) newPaths.push(path);
|
|
457
|
+
}
|
|
458
|
+
return { files, newPaths };
|
|
459
|
+
}
|
|
460
|
+
|
|
406
461
|
/**
|
|
407
462
|
* Commits a dead run's uncommitted work to the run's own branch and pushes it,
|
|
408
463
|
* so that the tree the next attempt destroys is no longer the only copy.
|
|
@@ -472,40 +527,42 @@ export async function salvageWip(
|
|
|
472
527
|
// only changes were ignored scratch had work by that test and none by this.
|
|
473
528
|
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
474
529
|
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
475
|
-
|
|
530
|
+
const cached = await git(["diff", "--cached", "--name-status"], worktree);
|
|
531
|
+
if (cached === "") {
|
|
476
532
|
return { kind: "nothing" };
|
|
477
533
|
}
|
|
534
|
+
const { files, newPaths } = parseCachedNameStatus(cached);
|
|
535
|
+
const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
|
|
478
536
|
await git(
|
|
479
537
|
[
|
|
480
538
|
...SALVAGE_COMMIT_CONFIG,
|
|
481
539
|
"commit",
|
|
482
540
|
"--no-verify",
|
|
483
541
|
"-m",
|
|
484
|
-
|
|
542
|
+
msg.subject,
|
|
543
|
+
"-m",
|
|
544
|
+
msg.body,
|
|
485
545
|
],
|
|
486
546
|
worktree,
|
|
487
547
|
);
|
|
488
548
|
const sha = await git(["rev-parse", "HEAD"], worktree);
|
|
549
|
+
const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
|
|
489
550
|
|
|
490
551
|
if (branch === "HEAD") {
|
|
491
552
|
// Detached: the commit is real but reachable only by sha, and pushing
|
|
492
553
|
// `HEAD` from here would publish a branch literally named HEAD.
|
|
493
554
|
return {
|
|
494
|
-
|
|
495
|
-
sha,
|
|
496
|
-
branch,
|
|
555
|
+
...salvaged,
|
|
497
556
|
pushed: false,
|
|
498
557
|
pushError: "detached HEAD — no branch to push",
|
|
499
558
|
};
|
|
500
559
|
}
|
|
501
560
|
|
|
502
561
|
const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
|
|
503
|
-
if (push.code === 0) return {
|
|
562
|
+
if (push.code === 0) return { ...salvaged, pushed: true };
|
|
504
563
|
|
|
505
564
|
return {
|
|
506
|
-
|
|
507
|
-
sha,
|
|
508
|
-
branch,
|
|
565
|
+
...salvaged,
|
|
509
566
|
pushed: false,
|
|
510
567
|
pushError: (
|
|
511
568
|
push.stderr.trim() ||
|