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.
@@ -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(now: Date, briefPath = "ORCHESTRATOR.md"): string {
233
- return `Tick ${now.toISOString()}: re-read ${briefPath} from disk, then run your standing loop from it.`;
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(): { scope: ReportScope; briefPath?: string; fallback?: string } {
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: join(project.workspaceRoot, "ORCHESTRATOR.md"),
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
- ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
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
- ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
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 { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
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 an orchestrator brief template to ${path}?`,
378
- `It is the standing prompt for your supervising session: duties, tiers, and boundaries, ` +
379
- `plus a release policy and a reporting section that are yours to edit. ` +
380
- `The conductor never reads it back — it stops at green PRs either way.`,
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 the existing ${ORCHESTRATOR_BRIEF_NAME}?`,
387
- `${path} already exists. Overwriting replaces it with the shipped template — any policy you wrote there is lost.`,
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 its "Releases" and "Reporting" sections; nothing here reads them back.`,
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
- if (!existsSync(path)) {
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
- `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME}.`,
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"), renderBriefForProject(p));
940
- ctx.ui.notify(formatBriefStatus(path, status), status.kind === "current" ? "info" : "warning");
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
- /** The operator's own brief, rendered into the project's workspace root. */
201
- export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
208
+ export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
202
209
 
203
- /** Shipped in `files[]`, so this resolves in an installed package too. */
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 the state
576
- * directory, so it is on the same disk the fleet already owns and survives a
577
- * reinstall of the package. Derived from the project rather than fixed, so a
578
- * project that ever gains a chosen workspace root keeps its brief with it.
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
- * The brief template exactly as shipped, placeholders and all.
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
- * The shipped template with a configured project's real values in it.
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 the rendered brief and returns where it went.
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 path = orchestratorBriefPath(a);
638
- mkdirSync(dirname(path), { recursive: true });
639
- writeFileSync(path, renderOrchestratorBrief(a));
640
- return path;
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
- " yours to edit afterwards release policy lives there, not in this package",
928
+ existsSync(briefPath) || existsSync(policyPath)
929
+ ? ` brief would OVERWRITE ${briefPath} + POLICY.md`
930
+ : ` brief would write ${briefPath} + POLICY.md`,
931
+ " POLICY.md is yoursReleases/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 render ${ORCHESTRATOR_BRIEF_NAME} — the one area that writes no config key`,
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
- | { kind: "salvaged"; sha: string; branch: string; pushed: boolean; pushError?: string }
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
- if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
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
- `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`,
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
- kind: "salvaged",
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 { kind: "salvaged", sha, branch, pushed: true };
562
+ if (push.code === 0) return { ...salvaged, pushed: true };
504
563
 
505
564
  return {
506
- kind: "salvaged",
507
- sha,
508
- branch,
565
+ ...salvaged,
509
566
  pushed: false,
510
567
  pushError: (
511
568
  push.stderr.trim() ||