omp-conductor 0.3.18 → 0.3.20
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 +352 -182
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +138 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +171 -3
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +537 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
package/src/brief-upgrade.ts
CHANGED
|
@@ -10,8 +10,20 @@
|
|
|
10
10
|
* banner can `retrofit` one at a classified cut before migrating.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
95
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
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.**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
package/src/briefs/worker.md
CHANGED
|
@@ -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.
|
|
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
|
|
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 {
|
|
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.
|
|
128
|
-
|
|
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,21 @@ 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 ? "already current" : "upgrade complete"}:\n` +
|
|
398
|
+
` Bun-global CLI omp-conductor@${result.version}\n` +
|
|
399
|
+
` omp plugin omp-conductor@${result.version}\n` +
|
|
400
|
+
` Herdr plugin herdr-conductor@${result.gitHead}\n` +
|
|
401
|
+
` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
|
|
402
|
+
` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
|
|
403
|
+
);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
401
406
|
case "daemon": {
|
|
402
407
|
// Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
|
|
403
408
|
// pidfile, which left a daemon started in the foreground (which is how
|
|
@@ -503,6 +508,10 @@ try {
|
|
|
503
508
|
break;
|
|
504
509
|
}
|
|
505
510
|
|
|
511
|
+
case "board":
|
|
512
|
+
await runBoard(flag(argv, "project"));
|
|
513
|
+
break;
|
|
514
|
+
|
|
506
515
|
case "hold": {
|
|
507
516
|
const r = hold(flag(argv, "project"));
|
|
508
517
|
process.stdout.write(
|
|
@@ -583,6 +592,44 @@ try {
|
|
|
583
592
|
break;
|
|
584
593
|
}
|
|
585
594
|
|
|
595
|
+
case "extend": {
|
|
596
|
+
const issue = issueArg("extend", argv[1]);
|
|
597
|
+
const maxTurns = turnsFlag(argv);
|
|
598
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
599
|
+
const daemon = livingDaemon();
|
|
600
|
+
if (daemon === undefined) throw new Error("daemon is not running");
|
|
601
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const response = await fetch(
|
|
607
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
|
|
608
|
+
{
|
|
609
|
+
method: "PUT",
|
|
610
|
+
headers: { "content-type": "application/json" },
|
|
611
|
+
body: JSON.stringify({ project: project.name, maxTurns }),
|
|
612
|
+
},
|
|
613
|
+
);
|
|
614
|
+
const payload = (await response.json()) as {
|
|
615
|
+
error?: unknown;
|
|
616
|
+
runId?: unknown;
|
|
617
|
+
maxTurns?: unknown;
|
|
618
|
+
};
|
|
619
|
+
if (!response.ok) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
|
|
625
|
+
throw new Error("daemon returned an invalid turn-extension response");
|
|
626
|
+
}
|
|
627
|
+
process.stdout.write(
|
|
628
|
+
`#${issue} turn ceiling extended to ${payload.maxTurns} (run ${payload.runId})\n`,
|
|
629
|
+
);
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
|
|
586
633
|
case "unblock": {
|
|
587
634
|
const issue = issueArg("unblock", argv[1]);
|
|
588
635
|
const cfg = loadConfig();
|
|
@@ -597,6 +644,44 @@ try {
|
|
|
597
644
|
break;
|
|
598
645
|
}
|
|
599
646
|
|
|
647
|
+
case "friction": {
|
|
648
|
+
const name = argv[1] as FrictionFeedbackName | undefined;
|
|
649
|
+
if (name === undefined || !Object.hasOwn(FRICTION_FEEDBACK_KINDS, name)) {
|
|
650
|
+
process.stderr.write(
|
|
651
|
+
"omp-conductor: friction needs one of: escalation-digest, report-noise, report-surprise\n",
|
|
652
|
+
);
|
|
653
|
+
process.exit(2);
|
|
654
|
+
}
|
|
655
|
+
const rawDetail = flag(argv, "detail");
|
|
656
|
+
const detail = rawDetail?.replace(/\s+/g, " ").trim();
|
|
657
|
+
if (
|
|
658
|
+
detail === undefined ||
|
|
659
|
+
detail.length === 0 ||
|
|
660
|
+
detail.length > 160 ||
|
|
661
|
+
rawDetail?.startsWith("--") === true
|
|
662
|
+
) {
|
|
663
|
+
process.stderr.write("omp-conductor: friction needs --detail with 1-160 characters\n");
|
|
664
|
+
process.exit(2);
|
|
665
|
+
}
|
|
666
|
+
const issueText = flag(argv, "issue");
|
|
667
|
+
const issue = issueText === undefined ? undefined : issueArg("friction --issue", issueText);
|
|
668
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
669
|
+
const store = openStore(dbPath());
|
|
670
|
+
try {
|
|
671
|
+
store.recordFriction(project.name, {
|
|
672
|
+
kind: FRICTION_FEEDBACK_KINDS[name],
|
|
673
|
+
occurrences: 1,
|
|
674
|
+
...(issue === undefined ? {} : { issue }),
|
|
675
|
+
sample: detail,
|
|
676
|
+
at: Date.now(),
|
|
677
|
+
});
|
|
678
|
+
} finally {
|
|
679
|
+
store.close();
|
|
680
|
+
}
|
|
681
|
+
process.stdout.write(`friction recorded for ${project.name}: ${name} — ${detail}\n`);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
|
|
600
685
|
case "pause":
|
|
601
686
|
setPaused(true);
|
|
602
687
|
process.stdout.write(
|