omp-conductor 0.3.18 → 0.3.19

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.
@@ -10,8 +10,20 @@
10
10
  * banner can `retrofit` one at a classified cut before migrating.
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
- import { dirname, join } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import {
15
+ constants,
16
+ copyFileSync,
17
+ existsSync,
18
+ linkSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ readdirSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { basename, dirname, join } from "node:path";
26
+ import { stateDir } from "./config.ts";
15
27
 
16
28
  /**
17
29
  * The line that divides the two halves. Matched on this substring rather than
@@ -355,28 +367,84 @@ export function shippedDiff(before: string, after: string): string {
355
367
  return lines.join("\n");
356
368
  }
357
369
 
370
+ /** Dedicated state-root directory for conductor-managed brief backups. */
371
+ export function briefBackupDir(): string {
372
+ return join(stateDir(), "backups", "briefs");
373
+ }
374
+
375
+ function backupTimestamp(): string {
376
+ return new Date().toISOString().replace(/[:.]/g, "-");
377
+ }
378
+
379
+ function copyToUniqueBackup(source: string, backupRoot: string, stem: string): string {
380
+ mkdirSync(backupRoot, { recursive: true });
381
+ const temporary = join(backupRoot, `.${stem}.${process.pid}.${randomUUID()}.tmp`);
382
+ copyFileSync(source, temporary, constants.COPYFILE_EXCL);
383
+ try {
384
+ for (let suffix = 0; ; suffix += 1) {
385
+ const destination = join(backupRoot, suffix === 0 ? stem : `${stem}-${suffix}`);
386
+ try {
387
+ // Linking a complete temp file publishes the backup atomically without
388
+ // overwriting a backup created concurrently at the same millisecond.
389
+ linkSync(temporary, destination);
390
+ return destination;
391
+ } catch (err) {
392
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") continue;
393
+ throw err;
394
+ }
395
+ }
396
+ } finally {
397
+ unlinkSync(temporary);
398
+ }
399
+ }
400
+
358
401
  /**
359
- * Writes content, leaving the previous file beside it when one existed.
402
+ * Moves only conductor's timestamp-shaped legacy sidecar backups into state.
403
+ * Unknown `.bak` files remain operator-owned. Copy-before-unlink also works
404
+ * when the workspace and state root are on different filesystems.
360
405
  */
361
- export function writeWithBackup(path: string, content: string): string | undefined {
362
- mkdirSync(dirname(path), { recursive: true });
363
- let backup: string | undefined;
364
- if (existsSync(path)) {
365
- backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
366
- writeFileSync(backup, readFileSync(path));
406
+ export function migrateLegacyBriefBackups(paths: readonly string[], backupRoot = briefBackupDir()): string[] {
407
+ const migrated: string[] = [];
408
+ for (const path of paths) {
409
+ const name = basename(path);
410
+ const pattern = new RegExp(
411
+ `^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.bak-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z$`,
412
+ );
413
+ if (!existsSync(dirname(path))) continue;
414
+ for (const entry of readdirSync(dirname(path), { withFileTypes: true })) {
415
+ if (!entry.isFile() || !pattern.test(entry.name)) continue;
416
+ const source = join(dirname(path), entry.name);
417
+ const destination = copyToUniqueBackup(source, backupRoot, entry.name);
418
+ unlinkSync(source);
419
+ migrated.push(destination);
420
+ }
367
421
  }
422
+ return migrated;
423
+ }
424
+
425
+ /** Writes content and stores any previous file under conductor state. */
426
+ export function writeWithBackup(
427
+ path: string,
428
+ content: string,
429
+ backupRoot = briefBackupDir(),
430
+ ): string | undefined {
431
+ mkdirSync(dirname(path), { recursive: true });
432
+ migrateLegacyBriefBackups([path], backupRoot);
433
+ const backup = existsSync(path)
434
+ ? copyToUniqueBackup(path, backupRoot, `${basename(path)}.bak-${backupTimestamp()}`)
435
+ : undefined;
368
436
  writeFileSync(path, content);
369
437
  return backup;
370
438
  }
371
439
 
372
440
  /**
373
- * Writes the merged brief, leaving the previous one beside it.
441
+ * Writes the merged brief, storing the previous one under conductor state.
374
442
  *
375
443
  * @deprecated Prefer {@link migrateToPolicy} / overlay refresh. Kept for
376
444
  * pre-overlay `--apply` on bannered single-file briefs.
377
445
  */
378
- export function writeMergedBrief(path: string, merged: string): string {
379
- const backup = writeWithBackup(path, merged);
446
+ export function writeMergedBrief(path: string, merged: string, backupRoot?: string): string {
447
+ const backup = writeWithBackup(path, merged, backupRoot);
380
448
  return backup ?? `${path}.bak-missing`;
381
449
  }
382
450
 
@@ -399,6 +467,8 @@ export function migrateToPolicy(opts: {
399
467
  floor: string;
400
468
  /** When set, use this owned text instead of splitting the live file. */
401
469
  owned?: string;
470
+ /** Override the conductor backup directory (tests). */
471
+ backupRoot?: string;
402
472
  }): MigrateResult {
403
473
  const live = readFileSync(opts.orchestratorPath, "utf8");
404
474
  const owned = opts.owned ?? splitBrief(live)?.owned;
@@ -408,9 +478,13 @@ export function migrateToPolicy(opts: {
408
478
  // Strip banner footers that an older split may have left in owned — never let
409
479
  // package chrome become fleet policy.
410
480
  const policyBody = stripLeadingBannerCrumbs(owned).replace(/^\s+/, "");
411
- const policyBackup = writeWithBackup(opts.policyPath, policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`);
481
+ const policyBackup = writeWithBackup(
482
+ opts.policyPath,
483
+ policyBody.endsWith("\n") ? policyBody : `${policyBody}\n`,
484
+ opts.backupRoot,
485
+ );
412
486
  const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
413
- const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
487
+ const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot);
414
488
  return {
415
489
  policyPath: opts.policyPath,
416
490
  orchestratorPath: opts.orchestratorPath,
@@ -430,6 +504,8 @@ export function repairPolicyBannerCrumbs(opts: {
430
504
  orchestratorPath: string;
431
505
  policyPath: string;
432
506
  floor: string;
507
+ /** Override the conductor backup directory (tests). */
508
+ backupRoot?: string;
433
509
  }): MigrateResult | undefined {
434
510
  if (!existsSync(opts.policyPath)) return undefined;
435
511
  const before = readFileSync(opts.policyPath, "utf8");
@@ -439,9 +515,13 @@ export function repairPolicyBannerCrumbs(opts: {
439
515
  writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, before));
440
516
  return undefined;
441
517
  }
442
- const policyBackup = writeWithBackup(opts.policyPath, cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`);
518
+ const policyBackup = writeWithBackup(
519
+ opts.policyPath,
520
+ cleaned.endsWith("\n") ? cleaned : `${cleaned}\n`,
521
+ opts.backupRoot,
522
+ );
443
523
  const composed = composeOrchestrator(opts.floor, readFileSync(opts.policyPath, "utf8"));
444
- const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed);
524
+ const orchestratorBackup = writeWithBackup(opts.orchestratorPath, composed, opts.backupRoot);
445
525
  return {
446
526
  policyPath: opts.policyPath,
447
527
  orchestratorPath: opts.orchestratorPath,
@@ -459,7 +539,10 @@ export function refreshComposedBrief(opts: {
459
539
  orchestratorPath: string;
460
540
  policyPath: string;
461
541
  floor: string;
542
+ /** Override the conductor backup directory (tests). */
543
+ backupRoot?: string;
462
544
  }): boolean {
545
+ migrateLegacyBriefBackups([opts.policyPath, opts.orchestratorPath], opts.backupRoot);
463
546
  if (!existsSync(opts.policyPath)) return false;
464
547
  const policy = readFileSync(opts.policyPath, "utf8");
465
548
  writeFileSync(opts.orchestratorPath, composeOrchestrator(opts.floor, policy));
@@ -558,9 +641,9 @@ export function proposeRetrofit(live: string): RetrofitResult {
558
641
  };
559
642
  }
560
643
 
561
- /** Insert the banner into a hand-written brief (with backup). */
562
- export function applyRetrofit(path: string, proposal: RetrofitProposal): string {
563
- const backup = writeWithBackup(path, proposal.retrofitted);
644
+ /** Insert the banner into a hand-written brief (with a state-root backup). */
645
+ export function applyRetrofit(path: string, proposal: RetrofitProposal, backupRoot?: string): string {
646
+ const backup = writeWithBackup(path, proposal.retrofitted, backupRoot);
564
647
  return backup ?? `${path}.bak-missing`;
565
648
  }
566
649
 
@@ -91,8 +91,9 @@ checked in this order:
91
91
  - **A PR closed without merging.** A human read the work and said no; the row says
92
92
  `failed`. Read the rejection before you touch anything — most of the time a
93
93
  review comment is a spec change. Fold what it says into the issue, then release
94
- the label so the next tick can attempt it again; the attempt counter still bounds
95
- it. If the answer was "this should not be built", take it off the queue instead.
94
+ the label so the next tick can attempt it again; the failed-attempt budget still
95
+ bounds repeated implementation failures. If the answer was "this should not be
96
+ built", take it off the queue instead.
96
97
  - **An open PR that is green.** That worker finished; it just never got to report.
97
98
  This is the "already done" case above — handle it exactly the same way. Never
98
99
  release-and-re-claim it — a fresh worker would duplicate a finished run.
@@ -106,10 +107,10 @@ checked in this order:
106
107
  pushed work lives on the remote, and unpushed commits live on the run's branch
107
108
  in the mirror, which a re-claim deliberately reattaches so the next worker
108
109
  starts from them with a **continuation brief** (read the log/diff first; do
109
- not recreate existing work). Note what exists and release the label; the
110
- attempt counter still bounds a loop of deaths. A turns-cap kill with attempts
111
- left is re-queued automatically by the daemon you should still notice it on
112
- drain, but you do not have to invent the continuation prompt.
110
+ not recreate existing work). Note what exists and release the label. Cap kills,
111
+ daemon orphans and answered blocks consume the separate bounded continuation
112
+ budget rather than failed implementation attempts. A turns-cap kill with room
113
+ left is re-queued automatically; you do not have to invent its prompt.
113
114
  - **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
114
115
  the next tick re-claim it clean.
115
116
 
@@ -204,7 +205,7 @@ rather than by editing policy prose. The five boundaries above are not.
204
205
 
205
206
  ## Learning loop
206
207
 
207
- `POLICY.md` is yours to amend, and amending it is part of the job. Two things
208
+ `POLICY.md` is yours to amend, and amending it is part of the job. Three things
208
209
  trigger an amendment:
209
210
 
210
211
  - **Your operator corrects you.** They told you to do something differently. That
@@ -212,16 +213,29 @@ trigger an amendment:
212
213
  - **Policy contradicts repo reality.** A duty or Releases step names machinery that
213
214
  no longer exists, or tells you to do something a repo's own `AGENTS.md` forbids.
214
215
  The repo wins.
216
+ - **Repeated friction points to policy.** A tick can carry a seven-day aggregate
217
+ of admission holds, recurring escalations, or reports classified as noise or
218
+ surprising. It is evidence to investigate, not permission to edit. Propose only
219
+ when the recurring cause has a safe `POLICY.md` remedy; a code, tracker, or
220
+ infrastructure defect follows the existing issue/escalation rules instead.
221
+
222
+ The daemon records repairable admission holds itself. You record judgments code
223
+ cannot make when the evidence is clear:
224
+ `omp-conductor friction escalation-digest --detail "<why>"`,
225
+ `omp-conductor friction report-noise --detail "<why>"`, or
226
+ `omp-conductor friction report-surprise --detail "<why>"` (add `--issue N` when
227
+ one issue anchors it). One observation changes nothing; only a repeated aggregate
228
+ can appear in a later tick.
215
229
 
216
230
  The protocol, in order:
217
231
 
218
232
  1. **Draft the exact replacement** against `POLICY.md`. Quote the lines as they
219
233
  stand, then the lines you propose. A diff, not a description of one. This full
220
234
  text is what you *apply* on a yes — it is not what you send.
221
- 2. **Ask, once — a single yes/no question, written for a phone.** It goes over
222
- the escalation channel (the `ask` tool it reaches your operator's Telegram),
223
- and Telegram renders none of your markdown: asterisks and backticks arrive as
224
- literal characters, and a pasted section becomes an unreadable wall. So:
235
+ 2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
236
+ call `telegram_ask`; never use the generic `ask` UI. Confirm that the tool
237
+ delivered the question to the configured Telegram chat. Telegram renders
238
+ none of your markdown, so asterisks and backticks arrive as literal characters:
225
239
  - Lead with one plain sentence: what changes, and why, in your own words.
226
240
  - Then show only the lines that actually change, compact, under two short
227
241
  labels like "now:" and "proposed:". Never paste whole sections around a
@@ -233,11 +247,13 @@ The protocol, in order:
233
247
  3. **On yes, apply it** by editing **`POLICY.md`** yourself — never the package
234
248
  floor, and never by relying on edits to the composed `ORCHESTRATOR.md` (that
235
249
  file is regenerated from the floor + `POLICY.md`). **On explicit no, drop
236
- it** forever and do not re-ask that amendment. **On cancel, timeout, or no
237
- answer**, park it that means "not now", not "never": mention it once in the
238
- next report as `pending amendment: <one-liner> say 'apply it' or 'drop it'`,
239
- never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
240
- A cancelled dialog is not a permanent rejection.
250
+ it** forever and do not re-ask that amendment. A cancelled or errored
251
+ `telegram_ask` is a delivery failure, not an operator answer. Re-deliver the
252
+ question with `telegram_send`, or report the channel as broken. Never infer
253
+ rejection or “not now” from failed delivery. On an explicit “not now”, park
254
+ it: mention it once in the next report as
255
+ `pending amendment: <one-liner> — say 'apply it' or 'drop it'`, never re-open
256
+ the yes/no dialog, and drop it if still unanswered after 7 days.
241
257
  4. **Log it.** Append one line to **Amendments** at the bottom of `POLICY.md`:
242
258
  the date, what triggered it, a one-sentence summary.
243
259
  5. **Offer general fixes upstream.** Ask one question of the amendment you just
@@ -112,7 +112,11 @@ or the full test suite on this host. It is shared, and CI owns the heavy gates.
112
112
  ```bash
113
113
  gh pr checks <pr> --repo {{REPO}} --watch --interval 30
114
114
  ```
115
- 5. **Green** stop and report `pushed-green`.
115
+ 5. After the watcher exits, read the exact remote head for the final report:
116
+ ```bash
117
+ gh pr view <pr> --repo {{REPO}} --json headRefOid --jq .headRefOid
118
+ ```
119
+ 6. **Green** → stop and report `pushed-green`.
116
120
  **Red** → diagnose the real cause and make **one** corrective push. Red a
117
121
  second time → stop, do not push again, and report `failed` with the failure
118
122
  digest (job name plus the decisive log lines).
@@ -148,11 +152,12 @@ Escalating is a successful outcome. Guessing is not.
148
152
 
149
153
  ## Your final report
150
154
 
151
- End with exactly these six lines, evidence only — no narration:
155
+ End with exactly these seven lines, evidence only — no narration:
152
156
 
153
157
  ```
154
158
  issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
155
159
  pr: <url or "none">
160
+ head: <40-character head SHA or "none">
156
161
  state: pushed-green | blocked | failed
157
162
  gates: <exact commands run and their results>
158
163
  changed: <files touched, one line>
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
+ import { runBoard } from "./board.ts";
10
11
  import {
11
12
  applyRetrofit,
12
13
  checkBrief,
@@ -21,7 +22,7 @@ import {
21
22
  writeMergedBrief,
22
23
  } from "./brief-upgrade.ts";
23
24
  import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
24
- import { dbPath, runDaemon, setPaused } from "./daemon.ts";
25
+ import { runDaemon, setPaused } from "./daemon.ts";
25
26
  import {
26
27
  armTicks,
27
28
  clearPaneHalt,
@@ -51,10 +52,20 @@ import {
51
52
  renderFloorForProject,
52
53
  shippedBriefTemplate,
53
54
  } from "./setup.ts";
54
- import { LIVE_STATES, openStore } from "./store.ts";
55
+ import { dbPath, LIVE_STATES, openStore } from "./store.ts";
56
+ import { formatTranscriptLine } from "./transcript.ts";
55
57
  import { makeTracker } from "./tracker/github.ts";
56
58
  import type { ProjectConfig } from "./types.ts";
57
59
  import { formatUnblock, unblockIssue } from "./unblock.ts";
60
+ import { upgradeConductor } from "./upgrade.ts";
61
+
62
+ const FRICTION_FEEDBACK_KINDS = {
63
+ "escalation-digest": "feedback:escalation-should-digest",
64
+ "report-noise": "feedback:report-noise",
65
+ "report-surprise": "feedback:report-surprise",
66
+ } as const;
67
+
68
+ type FrictionFeedbackName = keyof typeof FRICTION_FEEDBACK_KINDS;
58
69
 
59
70
  function packageVersion(): string {
60
71
  const parsed = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as {
@@ -73,6 +84,8 @@ usage:
73
84
  omp-conductor --version
74
85
  omp-conductor stop
75
86
  omp-conductor restart [--port N] [--project NAME]
87
+ omp-conductor upgrade [--to VERSION] [--project NAME]
88
+ omp-conductor board [--project NAME]
76
89
  omp-conductor status [--project NAME]
77
90
  omp-conductor hold [--project NAME]
78
91
  omp-conductor halt [--pane] [--project NAME]
@@ -80,14 +93,20 @@ usage:
80
93
  omp-conductor disarm [--project NAME]
81
94
  omp-conductor release-pane [--project NAME]
82
95
  omp-conductor tail <issue> [--project NAME]
96
+ omp-conductor extend <issue> --turns N [--project NAME]
83
97
  omp-conductor unblock <issue> [--project NAME]
84
98
  omp-conductor daemon [--once] [--port N] [--project NAME]
85
99
  omp-conductor pause
86
100
  omp-conductor resume
87
101
  omp-conductor graph-setup [--project NAME] [--write]
88
102
  omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
103
+ omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
89
104
  omp-conductor help
90
105
 
106
+ upgrade update the Bun-global CLI, omp plugin, Herdr recovery plugin, and
107
+ brief as one pinned release. Pauses only new claims, drains live
108
+ workers, reloads, verifies twice, and restores the prior dispatch
109
+ state. Run it from a shell outside the target Herdr session.
91
110
  start start the installed herdr-fleet.service when present, then run the
92
111
  dispatch loop in the background and wait until it answers GET
93
112
  /healthz on :8787 (override with --port). Refuses if one is running.
@@ -102,6 +121,9 @@ usage:
102
121
  status layered fleet report: dispatch (running|paused|stopped), ticks and
103
122
  next due time, pane, herdr, Telegram bot/API health, daemon, caps
104
123
  and active runs.
124
+ board open the live keyboard-driven fleet board. It renders queue holds,
125
+ every run lifecycle stage, recent merges, spend and health; Enter
126
+ follows a selected transcript without leaving the board.
105
127
  hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
106
128
  This is "stop the conductor overnight" without killing processes.
107
129
  halt hold, then stop the dispatch daemon (systemctl-aware). Pane stays up
@@ -121,11 +143,17 @@ usage:
121
143
  the daemon rather than terminals, so this is the only way to watch
122
144
  one live. Runs until Ctrl-C, or until the run has finished and its
123
145
  transcript has stopped growing.
146
+ extend monotonically raise a live run's turn ceiling without restarting its
147
+ session. Refuses settled runs and values at or below its current cap.
124
148
  unblock clear <issue>'s blocked and failed labels so the next tick can claim
125
149
  it again — the supported way back for an escalation you answered,
126
150
  and why the brief's "never hand-edit a state label" rule can stay
127
- absolute. Attempts already spent are kept: an answered block still
128
- cost a worker.
151
+ absolute. Run history is kept; answered blocks consume the separate
152
+ operational-continuation budget, not failed implementation attempts.
153
+ friction record a bounded observation the daemon cannot classify itself:
154
+ an escalation that belonged in a digest, or a tick report that was
155
+ noise/surprising. Repeated observations feed the existing Learning
156
+ loop; recording one never edits policy by itself.
129
157
  daemon run the dispatch loop in the foreground; --once runs a single tick
130
158
  and exits. This is what \`start\` launches.
131
159
  pause stop claiming new work only (ticks keep firing if armed). Prefer hold.
@@ -187,6 +215,17 @@ function portFlag(argv: string[]): number | undefined {
187
215
  return port;
188
216
  }
189
217
 
218
+ /** Required positive integer for `extend`; no partial parses such as `180x`. */
219
+ function turnsFlag(argv: string[]): number {
220
+ const raw = flag(argv, "turns");
221
+ const turns = raw === undefined ? Number.NaN : Number(raw);
222
+ if (!Number.isSafeInteger(turns) || turns < 1) {
223
+ process.stderr.write(`omp-conductor: extend needs --turns with a positive integer, got "${raw ?? ""}"\n`);
224
+ process.exit(2);
225
+ }
226
+ return turns;
227
+ }
228
+
190
229
  function humanDuration(ms: number): string {
191
230
  const s = Math.max(0, Math.round(ms / 1000));
192
231
  if (s < 60) return `${s}s`;
@@ -258,55 +297,6 @@ function issueArg(verb: string, raw: string | undefined): number {
258
297
  return issue;
259
298
  }
260
299
 
261
- /** Read one property off an unvalidated transcript entry. */
262
- function prop(source: unknown, key: string): unknown {
263
- if (source === null || typeof source !== "object") return undefined;
264
- return Reflect.get(source, key);
265
- }
266
-
267
- /**
268
- * One transcript line rendered for somebody watching, or `undefined` for the
269
- * lines not worth a row: thinking blocks, tool results, session metadata, and
270
- * anything this parser does not recognise.
271
- *
272
- * Defensive throughout. The transcript is written by the harness, not by this
273
- * package, so its shape is a peer dependency's business and can gain entry
274
- * types without warning. A `tail` that dies on one unfamiliar line is strictly
275
- * worse than one that skips it — the operator is watching a run they have no
276
- * other window onto.
277
- */
278
- function formatTranscriptLine(line: string): string | undefined {
279
- let entry: unknown;
280
- try {
281
- entry = JSON.parse(line);
282
- } catch {
283
- return undefined;
284
- }
285
- if (prop(entry, "type") !== "message") return undefined;
286
- const message = prop(entry, "message");
287
- if (prop(message, "role") !== "assistant") return undefined;
288
-
289
- const content = prop(message, "content");
290
- // The harness writes an array of blocks; a bare string is the degenerate form
291
- // some sessions still produce, and dropping it would silently lose the text.
292
- if (typeof content === "string") {
293
- return content.trim() === "" ? undefined : `assistant: ${content.trim()}`;
294
- }
295
-
296
- const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
297
- const out: string[] = [];
298
- for (const block of blocks) {
299
- const type = prop(block, "type");
300
- if (type === "text") {
301
- const text = prop(block, "text");
302
- if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
303
- } else if (type === "toolCall") {
304
- const name = prop(block, "name");
305
- if (typeof name === "string" && name !== "") out.push(`tool: ${name}`);
306
- }
307
- }
308
- return out.length === 0 ? undefined : out.join("\n");
309
- }
310
300
 
311
301
  /**
312
302
  * Follow one run's transcript the way `tail -f` follows a log.
@@ -398,6 +388,19 @@ try {
398
388
  case "version":
399
389
  process.stdout.write(`${packageVersion()}\n`);
400
390
  break;
391
+ case "upgrade": {
392
+ const result = await upgradeConductor({
393
+ version: flag(argv, "to"),
394
+ project: flag(argv, "project"),
395
+ });
396
+ process.stdout.write(
397
+ result.alreadyCurrent
398
+ ? `conductor ${result.version} already current on all three surfaces\n`
399
+ : `upgraded conductor ${result.previousVersion} → ${result.version} (${result.gitHead})\n` +
400
+ `dispatch ${result.dispatch} restored\n`,
401
+ );
402
+ break;
403
+ }
401
404
  case "daemon": {
402
405
  // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
403
406
  // pidfile, which left a daemon started in the foreground (which is how
@@ -503,6 +506,10 @@ try {
503
506
  break;
504
507
  }
505
508
 
509
+ case "board":
510
+ await runBoard(flag(argv, "project"));
511
+ break;
512
+
506
513
  case "hold": {
507
514
  const r = hold(flag(argv, "project"));
508
515
  process.stdout.write(
@@ -583,6 +590,44 @@ try {
583
590
  break;
584
591
  }
585
592
 
593
+ case "extend": {
594
+ const issue = issueArg("extend", argv[1]);
595
+ const maxTurns = turnsFlag(argv);
596
+ const project = findProject(loadConfig(), flag(argv, "project"));
597
+ const daemon = livingDaemon();
598
+ if (daemon === undefined) throw new Error("daemon is not running");
599
+ if (daemon.project !== undefined && daemon.project !== project.name) {
600
+ throw new Error(
601
+ `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
602
+ );
603
+ }
604
+ const response = await fetch(
605
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
606
+ {
607
+ method: "PUT",
608
+ headers: { "content-type": "application/json" },
609
+ body: JSON.stringify({ project: project.name, maxTurns }),
610
+ },
611
+ );
612
+ const payload = (await response.json()) as {
613
+ error?: unknown;
614
+ runId?: unknown;
615
+ maxTurns?: unknown;
616
+ };
617
+ if (!response.ok) {
618
+ throw new Error(
619
+ typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
620
+ );
621
+ }
622
+ if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
623
+ throw new Error("daemon returned an invalid turn-extension response");
624
+ }
625
+ process.stdout.write(
626
+ `#${issue} turn ceiling extended to ${payload.maxTurns} (run ${payload.runId})\n`,
627
+ );
628
+ break;
629
+ }
630
+
586
631
  case "unblock": {
587
632
  const issue = issueArg("unblock", argv[1]);
588
633
  const cfg = loadConfig();
@@ -597,6 +642,44 @@ try {
597
642
  break;
598
643
  }
599
644
 
645
+ case "friction": {
646
+ const name = argv[1] as FrictionFeedbackName | undefined;
647
+ if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
648
+ process.stderr.write(
649
+ "omp-conductor: friction needs one of: escalation-digest, report-noise, report-surprise\n",
650
+ );
651
+ process.exit(2);
652
+ }
653
+ const rawDetail = flag(argv, "detail");
654
+ const detail = rawDetail?.replace(/\s+/g, " ").trim();
655
+ if (
656
+ detail === undefined ||
657
+ detail.length === 0 ||
658
+ detail.length > 160 ||
659
+ rawDetail?.startsWith("--") === true
660
+ ) {
661
+ process.stderr.write("omp-conductor: friction needs --detail with 1-160 characters\n");
662
+ process.exit(2);
663
+ }
664
+ const issueText = flag(argv, "issue");
665
+ const issue = issueText === undefined ? undefined : issueArg("friction --issue", issueText);
666
+ const project = findProject(loadConfig(), flag(argv, "project"));
667
+ const store = openStore(dbPath());
668
+ try {
669
+ store.recordFriction(project.name, {
670
+ kind: FRICTION_FEEDBACK_KINDS[name],
671
+ occurrences: 1,
672
+ ...(issue === undefined ? {} : { issue }),
673
+ sample: detail,
674
+ at: Date.now(),
675
+ });
676
+ } finally {
677
+ store.close();
678
+ }
679
+ process.stdout.write(`friction recorded for ${project.name}: ${name} — ${detail}\n`);
680
+ break;
681
+ }
682
+
600
683
  case "pause":
601
684
  setPaused(true);
602
685
  process.stdout.write(
package/src/config.ts CHANGED
@@ -20,14 +20,17 @@ import {
20
20
  CONFIG_VERSION,
21
21
  DEFAULT_AUTHORITY,
22
22
  DEFAULT_CAPS,
23
+ DEFAULT_RELEASE_POLICY,
23
24
  DEFAULT_REPORT_SCOPE,
24
25
  ORCHESTRATOR_MODES,
25
26
  READABLE_CONFIG_VERSIONS,
27
+ RELEASE_POLICIES,
26
28
  REPORT_SCOPES,
27
29
  type Caps,
28
30
  type ConductorConfig,
29
31
  type ProjectConfig,
30
32
  type ReportScope,
33
+ type ReleasePolicy,
31
34
  type RepoTarget,
32
35
  } from "./types.ts";
33
36
 
@@ -137,24 +140,28 @@ export function saveConfig(c: ConductorConfig): void {
137
140
  }
138
141
 
139
142
  /**
140
- * Layers a project's overrides on the global defaults, field by field, so a
141
- * project that pins one cap still inherits the other five. `??` not `||`: a
142
- * deliberate `dailySpendUsd: 0` is a hard stop, not "unset", and `null` is a
143
- * deliberate "no spend gate" that must not fall through to the default.
144
- * Spelled out per field so adding a `Caps` member fails to compile here.
143
+ * Layers a project's overrides on the global defaults, field by field. `??`
144
+ * not `||`: a deliberate `dailySpendUsd: 0` is a hard stop, and `null` is a
145
+ * deliberate "no spend gate". Spelled out so a new cap fails compilation here.
145
146
  */
146
147
  export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
147
148
  const o: Partial<Caps> = p.caps ?? {};
148
149
  return {
149
150
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
150
- // nullish only — `null` means off; do not coalesce it to the default.
151
151
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
152
152
  workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
153
153
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
154
154
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
155
+ maxContinuationsPerIssue:
156
+ o.maxContinuationsPerIssue ?? defaults.maxContinuationsPerIssue,
155
157
  };
156
158
  }
157
159
 
160
+ /** Old configs and omitted keys are fail-closed at the enforcement boundary. */
161
+ export function resolveReleasePolicy(p: ProjectConfig): ReleasePolicy {
162
+ return p.releasePolicy ?? DEFAULT_RELEASE_POLICY;
163
+ }
164
+
158
165
  /**
159
166
  * Resolves a project by name, or the only project when the name is omitted.
160
167
  * Refuses to guess between several: picking one silently would spend the wrong
@@ -275,6 +282,14 @@ function normalizeProject(
275
282
 
276
283
  const escalation = normalizeEscalation(raw["escalation"], label, problems);
277
284
  const authority = normalizeAuthority(raw["authority"], label, problems);
285
+ const releasePolicy = pickLiteral(
286
+ raw["releasePolicy"],
287
+ RELEASE_POLICIES,
288
+ DEFAULT_RELEASE_POLICY,
289
+ `${label}: releasePolicy`,
290
+ RELEASE_POLICIES.map((value) => JSON.stringify(value)).join(" or "),
291
+ problems,
292
+ );
278
293
 
279
294
  const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
280
295
  const reporting = normalizeReporting(raw["reporting"], label, problems);
@@ -300,6 +315,7 @@ function normalizeProject(
300
315
  ...(workerModel === undefined ? {} : { workerModel }),
301
316
  escalation,
302
317
  authority,
318
+ releasePolicy,
303
319
  reporting,
304
320
  workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
305
321
  mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),