omp-conductor 0.4.2 → 0.4.3

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.
@@ -267,6 +267,15 @@ Not yours to relax:
267
267
  redo the merge costs a whole attempt to buy what one call does in minutes — do
268
268
  not. Bypassing branch protection with admin rights is still forbidden;
269
269
  updating the branch is how you satisfy it, not how you dodge it.
270
+ When `conductor_pr_update_branch` cannot resolve it — a real conflict, or a
271
+ generated file a server-side merge cannot recompute — and the run has settled
272
+ so no worker is coming, repairing the branch from a **fresh clone** is yours
273
+ and is expected: clone outside every worktree, merge the base, resolve,
274
+ regenerate what must be regenerated, verify locally, and push without force.
275
+ Take the commit identity from the repository's own history; never invent one.
276
+ The prohibition is the worktree, the force-push and the fabricated identity —
277
+ not the branch. A settled run means nobody else is coming:
278
+ waiting is not caution, it is a stall.
270
279
 
271
280
  ## Your verb surface
272
281
 
@@ -297,16 +306,21 @@ to read the refusal instead of retrying in a loop.
297
306
  the SHA that produced it. Never work around one: there is no path around it, and
298
307
  the attempt is in the ledger.
299
308
 
300
- **Two of the boundaries above are mechanical, not only prose.** Your structured
301
- file tools (`read`, `write`, `edit`, `grep`, `glob`) run against an allowlist:
302
- the state directory, this brief, `POLICY.md`, the heartbeat's own files, and any
303
- path your operator added as `orchestratorReadPaths`. Every worker checkout, the
304
- mirror cache and the installed `omp-conductor` package are refused reading
305
- included, and no config entry can re-open them. A refusal names the path and
306
- the roots that are allowed: read it and pick a different path rather than
307
- trying variations. When you genuinely need to see a run's code, read its PR.
308
- `bash` is not gated, and that is not an invitation — the boundary is the same
309
- one, and going around it is the one thing a fleet cannot audit.
309
+ **Your file tools are not gated the workers' are.** Every worker session runs
310
+ under a mechanical worktree gate and, under `credentials.isolation: "per-run"`,
311
+ its own OS principal. Your structured tools are gated by nothing, and that is a
312
+ deliberate operator decision rather than an oversight. What holds YOUR
313
+ boundaries is this brief, the verb ledger every `conductor_*` call writes, and
314
+ the operator reading both. Never read or edit a worker's checkout or the mirror
315
+ cache when you need a run's code, read its PR. Unconfined means auditable, not
316
+ licensed.
317
+
318
+ One caveat, so you never assume more protection than you have: if this daemon
319
+ started you *and* the fleet runs an isolating credentials mechanism, you are
320
+ also a separate OS principal that genuinely cannot reach a run checkout, the
321
+ mirror or the host credential — `omp-conductor status` names that mechanism on
322
+ its `boundary` line. Started by your operator, or on a host that reports
323
+ `unprotected`, nothing stops those reads but the paragraph above.
310
324
 
311
325
  **Your own** merge and release authority is not decided here, and not by this
312
326
  brief either. It is whatever your operator granted at setup time, and the
@@ -367,9 +381,7 @@ The protocol, in order:
367
381
  `telegram_ask` is a delivery failure, not an operator answer. Re-deliver the
368
382
  question with `telegram_send`, or report the channel as broken. Never infer
369
383
  rejection or “not now” from failed delivery. On an explicit “not now”, park
370
- it: mention it once in the next report as
371
- `pending amendment: <one-liner> — say 'apply it' or 'drop it'`, never re-open
372
- the yes/no dialog, and drop it if still unanswered after 7 days.
384
+ it in the ledger rather than in your head see below.
373
385
  4. **Log it.** Append one line to **Amendments** at the bottom of `POLICY.md`:
374
386
  the date, what triggered it, a one-sentence summary.
375
387
  5. **Offer general fixes upstream.** Ask one question of the amendment you just
@@ -381,6 +393,32 @@ The protocol, in order:
381
393
  diff and the incident that triggered it. File it only when your operator says
382
394
  yes — it is their name on the account.
383
395
 
396
+ **Every question you put to your operator is recorded, not remembered.** The
397
+ moment you send one — an amendment, a tier-2 decision, anything you are waiting
398
+ on an answer for — write it down:
399
+
400
+ ```
401
+ omp-conductor decision open --question "<the question as you sent it>" \
402
+ [--blocks "<what is waiting on it>"] [--resolves-when <condition>]
403
+ omp-conductor decision resolve <id> --answer "<what they decided>"
404
+ omp-conductor decision withdraw <id> --reason "<why it stopped mattering>"
405
+ ```
406
+
407
+ Use `--resolves-when` whenever the answer only becomes actionable once something
408
+ observable happens: `pr-merged:<url>`, `issue-closed:<n>`,
409
+ `npm-version:<pkg>@<version>`. The daemon checks it for you and flags the row as
410
+ `[CONDITION MET — act on this now]` in your tick digest, so a parked question
411
+ wakes up on its own instead of waiting for you to think of it.
412
+
413
+ **Trust the digest, never your recollection.** Every tick's prompt lists what is
414
+ still open. A question that exists only in your context is gone at the next
415
+ compaction, and you will either ask it twice or drop it silently — both of which
416
+ look, to your operator, like a fleet that does not follow up. Resolve or withdraw
417
+ each row explicitly; expiry is enforced for you at seven days, so an unanswered
418
+ question closes itself rather than accumulating. Re-mention a parked item once in
419
+ your next report as `pending amendment: <one-liner> — say 'apply it' or 'drop
420
+ it'`, and never re-open the yes/no dialog.
421
+
384
422
  Two limits. You never propose relaxing **Hard boundaries** — that section changes
385
423
  only in the shipped package floor, never via this loop. And at most one proposal
386
424
  per tick: an amendment waits for the three duties to finish, it never interrupts
@@ -59,12 +59,16 @@ and spot issues that would collide. Ask an omp session to read
59
59
 
60
60
  ## Reporting
61
61
 
62
- Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
62
+ Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
63
63
 
64
64
  - **`escalations`** — you speak when a human is needed, and once a day otherwise.
65
65
  That is: every tier-2 escalation immediately, carrying the issue link and the
66
66
  single question; plus one daily digest naming what merged, what is green and
67
67
  waiting on a merge, and what is stuck and why. Every other tick is silent.
68
+ - **`decisions`** — tier-2 escalations immediately, and a fleet-stopping
69
+ condition immediately; every other material event is held and delivered as one
70
+ message with the next tick report. A merge at 10:04 and a green PR at 10:09
71
+ arrive together at the 10:15 tick, not as two pings.
68
72
  - **`material`** — everything in `escalations`, plus each material event as it
69
73
  happens: a run reaching a green PR (with the link), a run that failed twice, an
70
74
  issue you pulled off the queue, a cap that stopped the fleet. A tick where
@@ -102,7 +106,7 @@ leaves no record that anything went out. And a `cancelled` or errored
102
106
  `telegram_send`, or report the channel as broken. It is never "asked once, no
103
107
  reply, dropped".
104
108
 
105
- Neither scope licenses narration. No progress updates, no "checking the queue
109
+ No scope licenses narration. No progress updates, no "checking the queue
106
110
  now", no restating this brief back. Evidence, or silence.
107
111
 
108
112
  ## Amendments
package/src/cli.ts CHANGED
@@ -11,18 +11,18 @@ import { dirname, join } from "node:path";
11
11
  import { runBoard } from "./board.ts";
12
12
  import {
13
13
  applyRetrofit,
14
- checkBrief,
15
- formatBriefStatus,
14
+ formatBriefReport,
16
15
  formatMigrateResult,
17
16
  formatRetrofitProposal,
18
17
  formatRetrofitRefusal,
19
18
  inspectBriefLayout,
20
19
  migrateToPolicy,
20
+ missingSections,
21
21
  proposeRetrofit,
22
22
  repairPolicyBannerCrumbs,
23
- writeMergedBrief,
24
23
  } from "./brief-upgrade.ts";
25
24
  import { findProject, loadConfig, resolveCaps, sharedRoot, stateDir } from "./config.ts";
25
+ import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
26
26
  import { boundarySetupScript } from "./credentials.ts";
27
27
  import { runDaemon, setPaused } from "./daemon.ts";
28
28
  import {
@@ -105,9 +105,14 @@ usage:
105
105
  omp-conductor pause
106
106
  omp-conductor resume
107
107
  omp-conductor graph-setup [--project NAME] [--write]
108
+ omp-conductor boundary-setup [--slots N] [--daemon-user USER] [--shared-root PATH]
108
109
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
109
110
  omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
110
111
  omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
112
+ omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
113
+ omp-conductor decision resolve <id> --answer TEXT [--project NAME]
114
+ omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
115
+ omp-conductor decision list [--project NAME]
111
116
  omp-conductor help
112
117
 
113
118
  upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
@@ -171,6 +176,17 @@ usage:
171
176
  at-least-once: a crash mid-send is retried and the retry says it may
172
177
  be a repeat. --kind digest is accepted at most once per local day,
173
178
  decided from the ledger rather than from what you remember sending.
179
+ decision record, list and close the questions you have put to your operator.
180
+ A question that lives only in a session's context is lost to the next
181
+ compaction, so \`decision open\` writes it down and every tick's prompt
182
+ carries what is still owed. \`--resolves-when\` attaches a condition the
183
+ daemon checks for you (pr-merged:<url>, issue-closed:<n>,
184
+ npm-version:<pkg>@<version>); a met one is flagged in that digest.
185
+ Unanswered rows expire after seven days rather than accumulating.
186
+ decision open --question TEXT [--blocks TEXT] [--resolves-when COND]
187
+ decision resolve <id> --answer TEXT
188
+ decision withdraw <id> [--reason TEXT]
189
+ decision list
174
190
  friction record a bounded observation the daemon cannot classify itself:
175
191
  an escalation that belonged in a digest, or a tick report that was
176
192
  noise/surprising. Repeated observations feed the existing Learning
@@ -186,14 +202,23 @@ usage:
186
202
  current. --write writes the two units and the script they run, and
187
203
  prints the systemctl line to run — it never runs systemctl itself.
188
204
  Exits 1 when no repo in the project has graphProject configured.
205
+ boundary-setup
206
+ print the root commands that provision the credential boundary
207
+ (#125): the per-run slot accounts, the conductor-daemon and
208
+ conductor-runs groups, the 0711 shared root, and the daemon home's
209
+ mode. Prints; never runs them — it creates system accounts, so it is
210
+ your sudo and your chance to read it first. Needs no config, and
211
+ must run BEFORE setup, which writes worktree and mirror paths into
212
+ the root this creates. --slots defaults to maxConcurrentWorkers.
189
213
  brief-upgrade
190
214
  inspect the brief overlay (package floor + POLICY.md). Reports by
191
215
  default. --migrate lifts a bannered ORCHESTRATOR.md owned half into
192
216
  POLICY.md and recomposes. --retrofit proposes inserting the YOURS TO
193
217
  EDIT banner before the first Releases/Project context/Reporting/
194
- Amendments heading (#20); --retrofit --apply writes it. Legacy
195
- --apply still merges a bannered single-file brief. --file checks a
196
- brief that is not where the wizard would have put it.
218
+ Amendments heading (#20); --retrofit --apply writes it. --apply only
219
+ ever confirms one of those two — the legacy single-file merge was
220
+ removed in 0.4.3. --file checks a brief that is not where the wizard
221
+ would have put it.
197
222
  help print this text (also --help, -h).
198
223
  --version
199
224
  print the installed omp-conductor package version (also -V, version).
@@ -804,6 +829,98 @@ try {
804
829
  break;
805
830
  }
806
831
 
832
+ case "decision": {
833
+ const sub = argv[1];
834
+ const project = findProject(loadConfig(), flag(argv, "project"));
835
+ const store = openStore(dbPath());
836
+ try {
837
+ if (sub === "open") {
838
+ const question = flag(argv, "question")?.trim();
839
+ if (question === undefined || question.length === 0 || question.startsWith("--")) {
840
+ process.stderr.write(
841
+ "omp-conductor: decision open needs --question with the question you sent\n",
842
+ );
843
+ process.exit(2);
844
+ }
845
+ const condition = flag(argv, "resolves-when")?.trim();
846
+ if (condition !== undefined && parseCondition(condition) === undefined) {
847
+ process.stderr.write(
848
+ `omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
849
+ );
850
+ process.exit(2);
851
+ }
852
+ const blocks = flag(argv, "blocks")?.trim();
853
+ const decision = store.createDecision({
854
+ project: project.name,
855
+ question,
856
+ ...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
857
+ ...(condition === undefined ? {} : { condition }),
858
+ at: Date.now(),
859
+ });
860
+ process.stdout.write(
861
+ `decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
862
+ );
863
+ break;
864
+ }
865
+
866
+ if (sub === "resolve" || sub === "withdraw") {
867
+ const id = argv[2];
868
+ if (id === undefined || id.startsWith("--")) {
869
+ process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
870
+ process.exit(2);
871
+ }
872
+ const answered = sub === "resolve";
873
+ const text = answered ? flag(argv, "answer")?.trim() : flag(argv, "reason")?.trim();
874
+ if (answered && (text === undefined || text.length === 0)) {
875
+ process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
876
+ process.exit(2);
877
+ }
878
+ const ok = store.resolveDecision(
879
+ id,
880
+ answered ? "answered" : "withdrawn",
881
+ text === undefined || text.length === 0 ? "withdrawn" : text,
882
+ Date.now(),
883
+ );
884
+ if (!ok) {
885
+ // A decision that is not open is a different mistake from an id that
886
+ // never existed, and the operator can only act on one of them.
887
+ process.stderr.write(
888
+ `omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
889
+ );
890
+ process.exit(1);
891
+ }
892
+ process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
893
+ break;
894
+ }
895
+
896
+ if (sub === "list" || sub === undefined) {
897
+ const open = store.openDecisions(project.name);
898
+ if (open.length === 0) {
899
+ process.stdout.write("no open decisions\n");
900
+ break;
901
+ }
902
+ const now = Date.now();
903
+ for (const d of open) {
904
+ const condition =
905
+ d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
906
+ const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
907
+ process.stdout.write(
908
+ `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
909
+ );
910
+ }
911
+ break;
912
+ }
913
+
914
+ process.stderr.write(
915
+ `omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
916
+ );
917
+ process.exit(2);
918
+ } finally {
919
+ store.close();
920
+ }
921
+ break;
922
+ }
923
+
807
924
  case "friction": {
808
925
  const name = argv[1] as FrictionFeedbackName | undefined;
809
926
  if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
@@ -962,7 +1079,7 @@ try {
962
1079
  if (layout.kind === "overlay") {
963
1080
  if (!argv.includes("--apply")) {
964
1081
  process.stdout.write(
965
- `${formatBriefStatus(path, layout)}\n\n` +
1082
+ `${formatBriefReport(path, layout, [])}\n\n` +
966
1083
  "POLICY.md already present. --migrate --apply will strip any leading\n" +
967
1084
  "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
968
1085
  );
@@ -981,7 +1098,7 @@ try {
981
1098
  });
982
1099
  if (repaired === undefined) {
983
1100
  process.stdout.write(
984
- `${formatBriefStatus(path, layout)}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
1101
+ `${formatBriefReport(path, layout, [])}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
985
1102
  );
986
1103
  } else {
987
1104
  process.stdout.write(`${formatMigrateResult(repaired)}\n`);
@@ -993,9 +1110,7 @@ try {
993
1110
  process.exit(1);
994
1111
  }
995
1112
  if (layout.kind === "legacy-handwritten") {
996
- process.stdout.write(
997
- `${formatBriefStatus(path, { kind: "unsplittable", missing: layout.missing })}\n`,
998
- );
1113
+ process.stdout.write(`${formatBriefReport(path, layout, layout.missing)}\n`);
999
1114
  process.exit(1);
1000
1115
  }
1001
1116
  if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
@@ -1028,36 +1143,38 @@ try {
1028
1143
  break;
1029
1144
  }
1030
1145
 
1031
- let live: string;
1032
- try {
1033
- live = readFileSync(path, "utf8");
1034
- } catch {
1146
+ // No `--migrate` / `--retrofit`: report the layout and what to run. The
1147
+ // legacy single-file merge that used to live here is gone (#131) — a bare
1148
+ // `--apply` now says so rather than silently doing nothing.
1149
+ if (argv.includes("--apply")) {
1035
1150
  process.stderr.write(
1036
- `omp-conductor: no brief at ${path}` +
1037
- `${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
1151
+ "brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
1038
1152
  );
1039
- process.exit(1);
1153
+ process.exit(2);
1040
1154
  }
1041
1155
 
1042
- if (layout.kind === "overlay") {
1043
- process.stdout.write(
1044
- `${formatBriefStatus(path, { kind: "overlay", policyPath: layout.policyPath, orchestratorPath: layout.orchestratorPath })}\n`,
1045
- );
1046
- break;
1156
+ let missing: string[] = [];
1157
+ if (layout.kind !== "overlay" && layout.kind !== "missing") {
1158
+ let live: string;
1159
+ try {
1160
+ live = readFileSync(path, "utf8");
1161
+ } catch {
1162
+ process.stderr.write(
1163
+ `omp-conductor: no brief at ${path}` +
1164
+ `${override === undefined ? " — run /conductor setup and say yes to writing ORCHESTRATOR.md." : "."}\n`,
1165
+ );
1166
+ process.exit(1);
1167
+ }
1168
+ missing = missingSections(live, rendered);
1047
1169
  }
1048
1170
 
1049
- const status = checkBrief(live, rendered);
1050
- process.stdout.write(`${formatBriefStatus(path, status)}\n`);
1051
- if (project === undefined) {
1171
+ process.stdout.write(`${formatBriefReport(path, layout, missing)}\n`);
1172
+ if (layout.kind !== "overlay" && project === undefined) {
1052
1173
  process.stdout.write(
1053
1174
  "\nnote: no conductor config resolved on this host, so the template's\n" +
1054
1175
  "{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
1055
1176
  );
1056
1177
  }
1057
- if (argv.includes("--apply") && status.kind === "mergeable") {
1058
- const backup = writeMergedBrief(path, status.merged);
1059
- process.stdout.write(`\napplied — previous brief kept at ${backup}\n`);
1060
- }
1061
1178
  break;
1062
1179
  }
1063
1180
 
package/src/config.ts CHANGED
@@ -231,6 +231,19 @@ export function saveConfig(c: ConductorConfig): void {
231
231
  * rewrote a live fleet's config into a dialect the previous release rejected.
232
232
  */
233
233
  export function writeConfigFile(c: unknown): void {
234
+ writeConfigRaw(`${JSON.stringify(c, null, 2)}\n`);
235
+ }
236
+
237
+ /**
238
+ * Atomic write of exact bytes.
239
+ *
240
+ * The rollback path needs this rather than {@link writeConfigFile}: it captured
241
+ * a snapshot as text, and putting it back through `JSON.parse` and
242
+ * `JSON.stringify` returns canonically formatted output, not the operator's
243
+ * file. Keys and values would survive; their formatting would not — and a
244
+ * rollback that reformats the thing it is restoring is not a restore.
245
+ */
246
+ export function writeConfigRaw(text: string): void {
234
247
  const dir = stateDir();
235
248
  const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
236
249
  // mkdir's mode is masked by umask; chmod only what we just created so an
@@ -240,7 +253,7 @@ export function writeConfigFile(c: unknown): void {
240
253
  const target = configPath();
241
254
  const tmp = join(dir, `.config.json.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
242
255
  try {
243
- writeFileSync(tmp, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
256
+ writeFileSync(tmp, text, { mode: 0o600 });
244
257
  renameSync(tmp, target);
245
258
  } catch (err) {
246
259
  rmSync(tmp, { force: true });
@@ -478,11 +491,6 @@ function normalizeProject(
478
491
  // (logged by `runWorker`) is what tells the operator the pattern missed.
479
492
  const rawWorkerModel = raw["workerModel"];
480
493
  const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
481
- const orchestratorReadPaths = normalizeOrchestratorReadPaths(
482
- raw["orchestratorReadPaths"],
483
- label,
484
- problems,
485
- );
486
494
 
487
495
  if (problems.length > before) return undefined;
488
496
 
@@ -504,7 +512,6 @@ function normalizeProject(
504
512
  policy,
505
513
  credentials,
506
514
  reporting,
507
- ...(orchestratorReadPaths === undefined ? {} : { orchestratorReadPaths }),
508
515
  // Derived from this project's own isolation, not from `stateDir()`: a
509
516
  // hand-written `per-run` config that omits these optional keys would
510
517
  // otherwise resolve under the 0700 private tree and be refused at dispatch,
@@ -814,8 +821,7 @@ function normalizeReleaseRequirements(
814
821
  * Open-ended by necessity — this package cannot know what a fleet publishes —
815
822
  * but a malformed entry still rejects the whole config rather than being
816
823
  * dropped. A silently dropped environment name is an operator debugging a
817
- * refusal that reads exactly like a correctly-denied one, which is the same
818
- * trap `orchestratorReadPaths` documents.
824
+ * refusal that reads exactly like a correctly-denied one.
819
825
  */
820
826
  function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
821
827
  if (parsed === undefined) return [];
@@ -1074,51 +1080,16 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
1074
1080
  }
1075
1081
 
1076
1082
  /**
1077
- * Extra roots the orchestrator's tool gate will let it read (#127).
1083
+ * `orchestratorReadPaths` was the allowlist extension for the orchestrator's
1084
+ * file gate (#127). The gate is gone — the orchestrator is unconfined by
1085
+ * operator ruling (#143) — and with it every reader of this key.
1078
1086
  *
1079
- * Fail-closed for the same reason `graphProject` is: the value is written by a
1080
- * human here and *enforced* in another process, by a session whose cwd is the
1081
- * state directory. A relative `../notes` would name a different directory for
1082
- * every reader, so it is an error rather than something resolved against
1083
- * whichever cwd happened to load the file — and a jail entry that resolves
1084
- * somewhere unintended is worse than no entry at all.
1085
- *
1086
- * A malformed entry rejects the whole config rather than being dropped: an
1087
- * operator who mistyped a path they believed they had granted would otherwise
1088
- * debug a refusal that reads exactly like a correctly-denied one.
1087
+ * It is deliberately *not* validated, and not rejected either: project-level
1088
+ * keys this build does not know are ignored, so a live fleet whose config still
1089
+ * carries it keeps loading. A retired key that failed validation would brick
1090
+ * exactly the fleets that adopted it, which is the 0.4.1→0.4.2 outage repeated
1091
+ * on purpose.
1089
1092
  */
1090
- function normalizeOrchestratorReadPaths(
1091
- parsed: unknown,
1092
- label: string,
1093
- problems: string[],
1094
- ): string[] | undefined {
1095
- if (parsed === undefined) return undefined;
1096
- if (!Array.isArray(parsed)) {
1097
- problems.push(`${label}.orchestratorReadPaths must be an array of absolute paths`);
1098
- return undefined;
1099
- }
1100
-
1101
- const paths: string[] = [];
1102
- parsed.forEach((entry: unknown, i) => {
1103
- if (!nonEmptyString(entry)) {
1104
- problems.push(
1105
- `${label}.orchestratorReadPaths[${i}] must be a non-empty absolute path, found ${JSON.stringify(entry)}`,
1106
- );
1107
- return;
1108
- }
1109
- const path = expandHome(entry.trim());
1110
- if (!isAbsolute(path)) {
1111
- problems.push(
1112
- `${label}.orchestratorReadPaths[${i}] must be an absolute path (or start with "~") — the ` +
1113
- `orchestrator session's cwd is the state directory, not wherever this file was edited — ` +
1114
- `found ${JSON.stringify(entry)}`,
1115
- );
1116
- return;
1117
- }
1118
- paths.push(path);
1119
- });
1120
- return paths.length === 0 ? undefined : paths;
1121
- }
1122
1093
 
1123
1094
  /**
1124
1095
  * Gates are the pre-push CI equivalent, so a malformed entry is an error, not